The Python Wait Timed Out. Why Is the Task Still Running?

asyncio can time out the wait without cancelling a shielded task. Find out which one was cancelled and who should own the result.

Share
An orange job capsule travels through a protective shield beside a stopwatch.

Your request catches TimeoutError. A moment later, the job it was waiting for writes a result anyway. Look for asyncio.wait_for(asyncio.shield(task), ...). The timeout cancels the shielded wait, while shield() keeps that cancellation from reaching the inner task. If the task should finish, give another part of your app ownership of its result. If it should stop, remove the shield and make sure the task actually cooperates with cancellation.

This is an AI-prepared, documentation-based explainer, not a personally reproduced incident. The request and worker examples are illustrative. See the editorial policy.

Which thing got cancelled?

Imagine a handler waiting for a report to finish. The report takes longer than the handler's response budget. The handler returns an error, and then the worker saves the report. The timeout did happen. It just belonged to the waiter, not the work.

With wait_for(task, timeout), expiry normally requests cancellation of the task and waits for cancellation to finish. With wait_for(shield(task), timeout), the awaitable being cancelled is the shield. The inner task can keep running. It can still be cancelled directly or by its actual owner, and shielding won't keep it alive if the process or event loop shuts down.

That difference is easy to miss because both calls can raise the same exception in the caller. After a timeout, check the original task you created, not the shield wrapper: task.done() tells you whether the inner task has finished, and task.cancelled() tells you whether it finished cancelled. If it is done and wasn't cancelled, its owner should still observe its result or exception. “Done” is a state, not a glowing performance review.

Isolate the wait from the work

Here's an illustrative probe. The job tells us it has started, then waits on an event. The caller gives the shield no more time, so the task can't finish until we release it. The probe intentionally keeps a strong reference to the inner task and awaits it before exiting.

import asyncio

async def main():
    started = asyncio.Event()
    release = asyncio.Event()

    async def job():
        started.set()
        await release.wait()
        return "complete"

    task = asyncio.create_task(job())
    await started.wait()
    try:
        await asyncio.wait_for(asyncio.shield(task), timeout=0)
    except TimeoutError:
        assert not task.done()
    else:
        raise AssertionError("expected the wait to time out")

    release.set()
    assert await task == "complete"

asyncio.run(main())

For the comparison, replace asyncio.shield(task) with task and change the assertion in the timeout handler to assert task.cancelled(). Remove the final release-and-await lines in that variant. The job has no code that suppresses cancellation, so the ordinary wait cancels it while it's blocked at the event. This fixture isolates the cancellation path; a real task can finish around the deadline, run asynchronous cleanup, or suppress CancelledError. An ordinary wait_for() can take longer than its nominal timeout while waiting for the task to settle.

Give the task an owner before the timeout happens

If the job is request-scoped, use the direct wait and let cancellation propagate after cleanup. Check that any library or remote service it calls has its own cancellation or deadline behavior. A cancelled Python task can't undo a write a remote service already accepted. Retrying an uncertain write needs its own reconciliation or idempotency strategy.

If the job should outlive the response, arrange for a supervisor to retain the task, observe its eventual result or exception, and handle shutdown. The Python docs explicitly warn that the loop only keeps weak references to tasks; a shield is no substitute for ownership. If the job must survive process loss, use a durable work queue instead of relying on an in-memory task. If you only need to stop waiting and inspect what's pending, asyncio.wait() offers another choice: a timeout returns pending tasks without cancelling them. You still have to decide who owns them.

Build one test for each contract: a request-owned task should observe cancellation, while a supervisor-owned task should finish after the waiter has gone and report any failure. This is different from a running Tokio blocking task, where the work itself can't be aborted once it has started. Here, the cancellation never reached the inner task through that wait in the first place.