AyushDoesDev. / Journal
All notes
Engineering notes··6 min read

Python async cancellation is control flow, not an error to hide

Timeouts only work when cancellation can travel through the whole call stack. Here is how to make async request handling predictable under load.

Share

The timeout that does not really stop the work

Imagine an API endpoint that calls a pricing service, reads a database row and then assembles a response. The caller disconnects after two seconds, but the application keeps waiting on a slow dependency. If every request leaves work behind, the service can spend capacity serving clients that are no longer there.

A timeout is useful only if it changes the lifetime of the work. In asyncio, cancellation is the mechanism that communicates that change. It is not merely a notification to log; it is part of the control flow of the coroutine.

Use a deadline around the operation

Python 3.11 introduced asyncio.timeout(), which scopes a deadline around a block of asynchronous work. When the deadline expires, the current task is cancelled. The context manager translates the cancellation it initiated into TimeoutError as the block exits, so catch TimeoutError outside the context manager.

  • Put the timeout around the whole logical operation, not just one convenient await.
  • Choose a budget that leaves time for response serialization and cleanup.
  • Avoid stacking unrelated per-call timeouts that can exceed the caller's total latency budget.

Do not swallow CancelledError

Cancellation is delivered by raising asyncio.CancelledError at an await point. Cleanup belongs in try/finally. If code catches CancelledError to perform cleanup, it should normally re-raise it afterwards. Silently converting cancellation into a success value can leave a TaskGroup or timeout scope believing work has completed normally when it has not.

This is especially important for libraries and shared service functions. A helper should not decide that the caller's cancellation is unimportant. The caller owns the lifetime of the operation.

A practical pattern

The following example keeps the deadline at the service boundary and guarantees that a resource is released whether the operation succeeds, fails or is cancelled:

  • async def load_order(order_id: str):
  • async with asyncio.timeout(2.0):
  • connection = await pool.acquire()
  • try:
  • return await connection.fetchrow(ORDER_SQL, order_id)
  • finally:
  • await pool.release(connection)

What to test before shipping

Test the slow path, not only the happy path. Make a dependency wait indefinitely, cancel the request task, and assert that the task finishes, the connection returns to the pool and no background task remains. Also test a coroutine that raises its own CancelledError so that application code does not accidentally reinterpret it as a normal timeout.

Structured concurrency with TaskGroup helps keep child tasks tied to a parent operation. It does not remove the need to design cancellation-safe cleanup or to make external clients honour deadlines.

The takeaway

Reliable async services treat cancellation as a first-class lifecycle event. Put deadlines around meaningful work, let cancellation propagate, and use finally blocks for cleanup. A timeout that only stops waiting while the underlying work continues is often just a more confusing way to leak capacity.