Timeouts and Cancellation
asyncio.timeout puts a deadline on a block; cancellation arrives as CancelledError, which you clean up after and re-raise.
async with asyncio.timeout(5.0): ... # TimeoutError raised as the block exitsA network call with no deadline can hang forever, and in async that is worse than in synchronous code: the task sits there holding a connection while the loop cheerfully accepts more work behind it.
asyncio.timeout
async with asyncio.timeout(delay) (3.11+) puts a deadline on a whole block. When it expires, the loop cancels the task running inside it and the context manager turns that cancellation into TimeoutError, raised as the block exits — so the except goes outside the async with, not around the awaited call. asyncio.wait_for(aw, timeout) is the older per-awaitable spelling; since 3.12 it is implemented in terms of timeout(), and since 3.11 it raises the built-in TimeoutError (asyncio.TimeoutError is an alias of it).
CancelledError is not a failure
Cancellation arrives as CancelledError, raised at whichever await the task was suspended on. It derives from BaseException (since 3.8), so except Exception deliberately misses it. If you do catch it, re-raise it:
try:
await call()
except asyncio.CancelledError:
log.info("cancelled")
raise # swallow this and every timeout above you breaks
finally:
await conn.aclose() # cleanup runs on the way out either wayThe task nobody cancelled
A timeout only cancels the task it is in. A task started with create_task and never awaited is not in it, so your handler returns and that task keeps running against a closed request. asyncio.shield(aw) is the deliberate form of this: it protects the inner awaitable while the await on it still raises.
Example
When to use it
- Putting a 2s budget on a handler that calls three upstream services.
- Stopping a worker pod cleanly on SIGTERM without dropping the message in flight.
- Diagnosing a connection pool that empties out because nothing has a deadline.
- Protecting a payment call from being abandoned when the client disconnects.
More examples
One deadline for the whole handler
A per-call timeout of 2s on four calls is an 8s request. A single budget around the block is the number your SLO actually talks about. The Timeout object returned by the context manager exposes when(), reschedule(when) and expired(), which is how you extend a deadline once you know more about the request.
import asyncio
import httpx
async def handle(client, uid, budget=2.0):
try:
# the budget covers everything inside, not each call separately
async with asyncio.timeout(budget) as cm:
user = (await client.get(f"/users/{uid}")).json()
if user["tier"] == "enterprise":
# Timeout.reschedule(when) moves the deadline, in loop.time() terms
cm.reschedule(asyncio.get_running_loop().time() + 10.0)
orders = (await client.get(f"/users/{uid}/orders")).json()
return {"user": user, "orders": orders}
except TimeoutError:
return {"error": "upstream too slow", "status": 504}Shielding the write you must not interrupt
shield(aw) stops the cancellation reaching the inner awaitable, but the await on it still raises, so the caller returns while the work continues. That is only safe when something else keeps a reference and eventually awaits the task. Use it for non-idempotent writes, and pair it with an idempotency key so a reconcile is possible.
import asyncio
async def charge_and_confirm(payments, order):
# the charge must not be abandoned halfway: shield it from cancellation
charge = asyncio.create_task(payments.charge(order))
try:
return await asyncio.shield(charge)
except asyncio.CancelledError:
# we are cancelled; the charge task is NOT, so hand it off and re-raise
order.pending_charge = charge # someone must still await it
raise
async def main(payments, order):
try:
async with asyncio.timeout(5):
await charge_and_confirm(payments, order)
except TimeoutError:
# the charge may still be in flight - reconcile, do not retry blindly
print("charge outcome unknown; reconcile by idempotency key")Cancelling everything on shutdown
Graceful shutdown is cancellation with a deadline. Because every worker re-raises CancelledError after its finally block, the TaskGroup exit waits for the cleanup rather than killing the process mid-write. loop.add_signal_handler is Unix-only; on Windows use signal.signal and a thread-safe call into the loop.
import asyncio
import signal
async def main():
stop = asyncio.Event()
loop = asyncio.get_running_loop()
# signal handlers run in the loop thread: just set the event
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, stop.set)
async with asyncio.TaskGroup() as tg:
workers = [tg.create_task(worker(i)) for i in range(4)]
await stop.wait() # block until a signal arrives
for w in workers:
w.cancel() # ask politely
# the TaskGroup waits for each worker's finally blocks to finish
Discussion