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:
task = asyncio.create_task(work())
task.cancel()
try:
await task
except asyncio.CancelledError:
... # the task finished cancellingCancelledError 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:
async def worker():
try:
await do_work()
except asyncio.CancelledError:
await flush_partial_state() # bounded cleanup
raise # never swallowThe mechanism: how a timeout becomes a TimeoutError
asyncio.timeout() (3.11+) is cancellation plus bookkeeping:
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 -> nothingThree details carry every surprise in this note:
- The timer is a loop callback. It runs only when the loop gets control, which a coroutine that never awaits does not give it.
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 andTaskGroups each stop the cancellation at their own boundary.- 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:
async with asyncio.timeout(0.1):
await busy() # time.sleep(0.5) inside, then an awaitends right after blocking: finished at 0.50 s, no TimeoutError
awaits again after blocking: TimeoutError at 0.50 sThe 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:
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()timeout(0.1) block finished at 1.11 s, no TimeoutError
task still marked cancelling: 0The 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:
try:
await asyncio.gather(*(call(i) for i in range(5)))
except ConnectionError:
... # handler moves ongather 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
async def boom():
raise ValueError("lost")
task = asyncio.create_task(boom()) # nobody awaits ittask failed at 0.00 s; reported so far: nothing, at 0.30 s
loop reported 'Task exception was never retrieved' at 0.30 sThe 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:
_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:
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
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:
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”:
await asyncio.shield(persist_result(row)) # survives caller cancellationIt 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.
Related
Interview angle 6
- “What does
task.cancel()actually do?” - requests aCancelledErrorat the coroutine’s nextawaitand 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
CancelledErrorderive fromBaseException?” - so routineexcept 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 noTimeoutErroris raised: a 0.1 s timeout ran 1.11 s in a measured run. TaskGroups and shutdown break the same way.
- Follow-up: what happens if a retry loop swallows it anyway? The
timeout’s cancellation is eaten,
- “
wait_fororasyncio.timeout?” -timeout()wraps a block and usesuncancel()to scope its cancellation, so nesting composes;wait_forwraps one awaitable. - “One of five
gathercalls raises. What happens to the other four?” - they keep running, unsupervised.TaskGroupcancels them and raises anExceptionGroup, 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
shieldto 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.