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:
- Task Wrapping: If the passed awaitable is a raw
coroutine rather than an existing
asyncio.Task,wait_for()wraps it into a task usingasyncio.ensure_future(). - Dispatching Cancellation: Upon timeout expiration,
wait_for()executestask.cancel(). This marks the task as cancelling and schedules anasyncio.CancelledErrorto be raised inside the coroutine at its next suspension point (await). - Awaiting Completion: Crucially,
wait_for()does not immediately raiseTimeoutErrorto the caller. It pauses and awaits the wrapped task until it finishes its cleanup and exits. - Error Translation: Once the task terminates in
response to the cancellation,
wait_for()catches the resultingCancelledErrorinternally and translates it into aTimeoutErrorfor 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 cancellationThe 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.