Backend / Async & concurrency / 13_cancellation_timeouts_errors.md

Cancellation, timeouts and error handling in asyncio

Updated 6 interview angles 7 min read source
On this page8
  1. Cancellation is an exception, not a kill
  2. The mechanism: how a timeout becomes a TimeoutError
  3. Where it breaks in production
  4. ExceptionGroup and except*
  5. Graceful shutdown
  6. The trade not taken: shield as durability
  7. Related
  8. Interview angle

Cancellation, timeouts and error handling in asyncio

Article.

The area that separates people who have used asyncio from people who have shipped it. One question runs through all of it: when something goes wrong or takes too long, what actually stops, and what silently keeps running? Every answer below was run on Python 3.14.5, because the honest answers are surprising.

Cancellation is an exception, not a kill

task.cancel() does not stop a task. It asks for a CancelledError to be raised inside the coroutine at its next await:

python
task = asyncio.create_task(work())
task.cancel()
try:
    await task
except asyncio.CancelledError:
    ...  # the task finished cancelling

CancelledError inherits from BaseException, not Exception (since 3.8), so a routine except Exception: lets it through. That is deliberate. A bare except: or except BaseException: does not, and that is a bug.

Catch it only to clean up, then re-raise:

python
async def worker():
    try:
        await do_work()
    except asyncio.CancelledError:
        await flush_partial_state()   # bounded cleanup
        raise                          # never swallow

The mechanism: how a timeout becomes a TimeoutError

asyncio.timeout() (3.11+) is cancellation plus bookkeeping:

text
enter     call_at(deadline, _on_timeout)
deadline  _on_timeout: task.cancel(), state = EXPIRING
          CancelledError raised at the task's next await
exit      handler cancelled; if EXPIRING:
            task.uncancel()
            block exited with CancelledError?
              yes -> raise TimeoutError
              no  -> nothing

Three details carry every surprise in this note:

  1. The timer is a loop callback. It runs only when the loop gets control, which a coroutine that never awaits does not give it.
  2. cancel() counts requests. task.cancelling() is the number of pending requests; uncancel() decrements it. That count is how a timeout tells its own cancellation from an outer one, so nested timeouts and TaskGroups each stop the cancellation at their own boundary.
  3. TimeoutError needs the CancelledError back. The conversion happens only if the block exits with CancelledError. A block that swallowed it, or finished before the timer ran, exits normally, and no error is raised.

wait_for(aw, timeout) still works and wraps one awaitable; timeout() wraps a block and composes. Since 3.11 asyncio.TimeoutError is the builtin TimeoutError.

Where it breaks in production

A timeout cannot interrupt code that never awaits

A 0.1-second timeout around 0.5 seconds of sync work:

python
async with asyncio.timeout(0.1):
    await busy()          # time.sleep(0.5) inside, then an await
text
ends right after blocking:   finished at 0.50 s, no TimeoutError
awaits again after blocking: TimeoutError at 0.50 s

The deadline passed while the loop was blocked. If the block finishes on its next step, the timeout is torn down before its timer ever runs and never fires. If it awaits again, the error arrives at 0.5 s, five times late.

“We have a timeout” protects nothing when the slow part is a sync driver, a CPU-bound parse or requests.get. The fix is getting that work off the loop (run_in_executor and asyncio.to_thread), and knowing that a timeout around to_thread still does not stop the thread.

A swallowed CancelledError disables the timeout

A retry loop that catches “any error”, which here includes cancellation:

python
async def stubborn():
    for _ in range(10):
        try:
            await asyncio.sleep(0.1)          # a call that might fail
        except asyncio.CancelledError:        # or a bare except:
            pass                               # "retry on anything"

async with asyncio.timeout(0.1):
    await stubborn()
text
timeout(0.1) block finished at 1.11 s, no TimeoutError
task still marked cancelling: 0

The timeout fired, its cancellation was eaten, and the block ran ten times longer with no error anywhere. The same swallow stops TaskGroup from cancelling siblings and makes graceful shutdown hang until the orchestrator’s SIGKILL. Grep for except BaseException, bare except: and suppress(BaseException) in async code; each is a candidate.

gather leaves the other calls running

Five calls, the first fails after 0.1 s:

python
try:
    await asyncio.gather(*(call(i) for i in range(5)))
except ConnectionError:
    ...                                         # handler moves on
text
gather raised ConnectionError('call 0') at 0.11 s; handler moves on
siblings that completed anyway afterwards: [1, 2, 3, 4]
TaskGroup raised 1 error(s) at 0.11 s
siblings that completed after the TaskGroup failed: []

gather surfaces the first exception and does not cancel the rest: four requests kept running against a dependency the caller had just declared broken, and nothing awaits their results. TaskGroup cancelled them and raised an ExceptionGroup.

Default to TaskGroup; keep gather(return_exceptions=True) for fan-outs where every outcome is wanted, such as a health check (TaskGroup and structured concurrency).

Fire-and-forget loses the exception

python
async def boom():
    raise ValueError("lost")

task = asyncio.create_task(boom())            # nobody awaits it
text
task failed at 0.00 s; reported so far: nothing, at 0.30 s
loop reported 'Task exception was never retrieved' at 0.30 s

The failure was reported only when the task object was garbage-collected, here when the last reference was dropped 0.3 s later. In a service that can be minutes later, or never, and it goes to the loop’s exception handler, not your error tracker. Without any reference, the loop’s weak reference also lets the task be collected mid-flight (“Task was destroyed but it is pending!”).

If you must fire and forget, keep a strong reference and observe the result:

python
_background: set[asyncio.Task] = set()

def spawn(coro) -> asyncio.Task:
    task = asyncio.create_task(coro)
    _background.add(task)                       # strong reference
    task.add_done_callback(_background.discard)
    task.add_done_callback(_log_if_failed)
    return task

def _log_if_failed(task: asyncio.Task) -> None:
    if not task.cancelled() and task.exception():
        log.error("background task failed", exc_info=task.exception())

Better still: own the task in a TaskGroup scoped to the request, or put the work on a real queue.

ExceptionGroup and except*

When siblings fail concurrently, more than one can fail, so TaskGroup always raises an ExceptionGroup, even for a single failure. That surprises people migrating from gather:

python
try:
    async with asyncio.TaskGroup() as tg:
        tg.create_task(a())
        tg.create_task(b())
except* ValueError as eg:          # note the star
    for err in eg.exceptions:
        log.warning("bad value: %s", err)
except* ConnectionError as eg:
    ...

except* selects the matching leaves, and more than one clause can run for a single raise.

Graceful shutdown

python
async def main():
    async with asyncio.TaskGroup() as tg:      # owns the workers
        tg.create_task(consume())
        tg.create_task(serve())

Exiting the block waits for both, or cancels both on failure. When an existing service cannot be wrapped in one group, the order is: stop accepting work, cancel outstanding tasks, then wait with a bound so one stuck task cannot hang the deploy:

python
for task in tasks:
    task.cancel()
async with asyncio.timeout(10):
    await asyncio.gather(*tasks, return_exceptions=True)

return_exceptions=True keeps the first CancelledError from ending the wait before the others finish cleaning up.

The trade not taken: shield as durability

asyncio.shield(aw) stops the caller’s cancellation reaching the inner awaitable. It is tempting for “this write must land”:

python
await asyncio.shield(persist_result(row))     # survives caller cancellation

It does not make the write durable. A direct cancel() on the inner task or loop shutdown still stops it, the process exiting kills it, and once the caller is gone nothing awaits it, so its exception goes unobserved, the same loss as fire-and-forget.

Work that must happen belongs in an outbox row written in the same transaction, a queue or a workflow engine (The transactional outbox). Shield is for bounded cleanup that you still await during shutdown, and rarely.

Interview angle 6

  • “What does task.cancel() actually do?” - requests a CancelledError at the coroutine’s next await and increments its cancel count. It is a request, not a kill.
    • Follow-up: a 0.1-second timeout wraps 0.5 seconds of sync work. When does it fire? At 0.5 s if the code awaits afterwards, and never if the block finishes on that step. Measured: “finished at 0.50 s, no TimeoutError”.
  • “Why does CancelledError derive from BaseException?” - so routine except Exception: handlers do not swallow a cancellation the runtime is trying to deliver.
    • Follow-up: what happens if a retry loop swallows it anyway? The timeout’s cancellation is eaten, __aexit__ sees a normal exit, and no TimeoutError is raised: a 0.1 s timeout ran 1.11 s in a measured run. TaskGroups and shutdown break the same way.
  • “wait_for or asyncio.timeout?” - timeout() wraps a block and uses uncancel() to scope its cancellation, so nesting composes; wait_for wraps one awaitable.
  • “One of five gather calls raises. What happens to the other four?” - they keep running, unsupervised. TaskGroup cancels them and raises an ExceptionGroup, which is the reason to default to it.
  • “What is wrong with asyncio.create_task(f()) on its own?” - its exception is reported only when the task is garbage-collected, to the loop’s handler, and without a strong reference it can be collected mid-flight.
  • “Would you use shield to make sure a payment record is written?” - no: it survives the caller’s cancellation, not the process exiting. That is an outbox or a queue.