How Asyncio wait_for Handles Cancellation on Timeout

In Python's asynchronous ecosystem, asyncio.wait_for() enforces an execution deadline on an awaitable object. When this deadline expires, asyncio.wait_for() does not simply abandon the underlying coroutine; it initiates an explicit cancellation sequence by injecting an asyncio.CancelledError into the target task, waits for the task to react to this cancellation, and subsequently raises a TimeoutError (or asyncio.TimeoutError in Python versions prior to 3.11). Understanding these cancellation mechanics is essential for preventing leaked background operations, unclosed resources, and unexpected program states.

The Cancellation Workflow

When a coroutine passed to asyncio.wait_for(fut, timeout=...) exceeds the specified duration, the event loop triggers the following sequence:

  1. Task Wrapping: If the passed awaitable is a raw coroutine rather than an existing asyncio.Task, wait_for() wraps it into a task using asyncio.ensure_future().
  2. Dispatching Cancellation: Upon timeout expiration, wait_for() executes task.cancel(). This marks the task as cancelling and schedules an asyncio.CancelledError to be raised inside the coroutine at its next suspension point (await).
  3. Awaiting Completion: Crucially, wait_for() does not immediately raise TimeoutError to the caller. It pauses and awaits the wrapped task until it finishes its cleanup and exits.
  4. Error Translation: Once the task terminates in response to the cancellation, wait_for() catches the resulting CancelledError internally and translates it into a TimeoutError for the caller.

Task Cleanup and Suspension Points

Because wait_for() waits for the inner task to terminate after cancelling it, the inner coroutine has the opportunity to run cleanup logic defined in try...finally or except asyncio.CancelledError blocks:

async def worker():
    try:
        await asyncio.sleep(10)
    except asyncio.CancelledError:
        # Cleanup actions occur here
        await close_resources()
        raise  # Re-raising is vital to confirm cancellation

The inner coroutine will only process the cancellation when it yields control back to the event loop. If the coroutine is performing synchronous, blocking CPU work without awaiting an asynchronous operation, the cancellation cannot be injected until the next await expression is reached.

The Cancellation Suppression Trap

A critical nuance in asyncio.wait_for() occurs when the wrapped coroutine intercepts and suppresses asyncio.CancelledError.

If a coroutine catches CancelledError and returns a value instead of propagating the exception:

async def swallowing_worker():
    try:
        await asyncio.sleep(10)
    except asyncio.CancelledError:
        return "suppressed"

In this scenario, wait_for() observes that the task completed successfully rather than terminating via cancellation. Consequently, wait_for() will not raise TimeoutError; instead, it returns the value "suppressed". To preserve correct timeout behavior, any handler catching asyncio.CancelledError inside the wrapped task must either re-raise it or allow it to bubble up.

Exception Precedence During Teardown

If the inner coroutine raises a different exception during its cancellation cleanup (such as an error occurring inside a finally block or within an except CancelledError handler), that new exception supersedes the cancellation. In this case, wait_for() will propagate the secondary exception to the caller instead of raising TimeoutError.

Preventing Inner Cancellation with asyncio.shield()

By default, the timeout and the inner operation are tightly coupled: timing out implies cancelling the operation. If an operation must continue running in the background even if the caller stops waiting for it, asyncio.shield() must be combined with wait_for():

# The task continues running even after wait_for raises TimeoutError
task = asyncio.create_task(critical_operation())
await asyncio.wait_for(asyncio.shield(task), timeout=5.0)

In this pattern, when the timeout expires, the cancellation signal is absorbed by the shield wrapper, leaving the underlying critical_operation() task running uninterrupted.