On this page
Description
Starting concurrent work in asyncio is easy. Ending it well is the hard part: when one of three calls fails, what happens to the other two? When a call takes too long, how do you stop it? When a user closes the page, does the work behind it stop too? Python 3.11 added two tools that answer these questions - TaskGroup and asyncio.timeout - and both depend on one mechanism: cancellation. This lesson covers all three, and the habits that keep cancellation working.
Here's the kind of bug we mean. A dashboard fetches a profile (slow) and orders (fast) with asyncio.gather. The orders service goes down, and the request fails after 100 ms - good. But the profile fetch keeps running in the background for its full duration, holding a database connection, for every failed request. During an outage the connection pool fills up with work nobody will ever read. By the end of the lesson you'll know why gather does this, and what to use instead.
We'll go from simple to more technical:
gatherand how it handles errors.TaskGroup: concurrent work that ends cleanly.- Catching errors from a group with
except*. - Timeouts.
- Cancellation - and the one rule you must never break.
gather and its errors
try:
await asyncio.gather(fetch_profile(uid), fetch_orders(uid))
except ValueError as e:
... # orders failed - but fetch_profile is still running
gather runs its arguments concurrently. When one of them raises:
- The first exception is passed straight to you, and the others are not cancelled. In a test, the 0.3 s profile fetch wasn't done when
gatherraised after 0.1 s - and it finished anyway, 0.2 s later, with nobody waiting for it. - With
return_exceptions=True, every call runs to the end and failures come back as values in the results list:['a', ValueError('orders service down')]. You then check each result yourself.
Neither mode stops the remaining work when something fails. For that you need a TaskGroup.
TaskGroup
async with asyncio.TaskGroup() as tg:
profile = tg.create_task(fetch_profile(uid))
orders = tg.create_task(fetch_orders(uid))
page = render(profile.result(), orders.result()) # both are done here
A TaskGroup (Python 3.11+) follows two simple rules:
- Nobody leaves early. The
async withblock doesn't end until every task started in it has finished. No task is forgotten in the background, and no exception goes unnoticed. - One fails, all stop. When a task raises, the group cancels the other tasks, waits for them to stop, and then raises. In a test, a failure after 0.1 s ended the whole group at 0.1 s, with the 0.3 s profile fetch cancelled rather than finished.
That's structured concurrency: concurrent work lives inside a block, like a function call, and is always finished - one way or another - when the block ends. Prefer TaskGroup to gather in new code. (Java's equivalent is StructuredTaskScope.)
Catching errors from a group: except*
A TaskGroup raises an ExceptionGroup - a bundle of exceptions - because several tasks can fail at the same time. It does so even when only one task failed. That means a plain except ValueError doesn't catch it:
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(fetch_orders(uid))
except ValueError:
... # never runs - the error is an ExceptionGroup
Python 3.11 added except* for this (PEP 654). It matches the exceptions of that type inside the group:
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(fetch_profile(uid))
tg.create_task(fetch_orders(uid))
except* ValueError as eg:
log.warning("dashboard failed: %s", eg.exceptions) # a tuple of every ValueError
If two tasks raised ValueError, eg.exceptions holds both.
Timeouts
Every call over a network needs a time limit. asyncio has two ways to set one:
async with asyncio.timeout(2.0): # Python 3.11+: a deadline for everything in the block
user = await get_user(uid)
orders = await get_orders(uid)
data = await asyncio.wait_for(fetch(url), 2.0) # a time limit for one call
Both cancel the work when time runs out and raise TimeoutError. Since 3.11, asyncio.TimeoutError is the same class as the built-in TimeoutError, so one except TimeoutError: covers both. asyncio.timeout is usually nicer: one deadline can cover several awaits, and it can be moved while it runs with reschedule().
Cancellation
TaskGroup, timeouts and server shutdown all stop work the same way: task.cancel(). That makes asyncio.CancelledError get raised inside the task, at the await where it's paused. The exception travels up through the task's code, running finally blocks and async with exits on the way, and the task ends as "cancelled".
This only works if the exception is allowed to travel. So:
CancelledErroris aBaseException, not anException(since Python 3.8). Anexcept Exception:block lets it through - which is exactly what you want.- Never swallow it. A coroutine that catches
CancelledError- or uses a bareexcept:orexcept BaseException:- and carries on breaks everything built on cancellation. In a test, a coroutine that caught it and returned a default value ran underasyncio.timeout(0.1): the block ended normally, with noTimeoutErrorat all. The timeout silently stopped working.
If you need to clean up when cancelled, use finally, or catch it, clean up, and raise it again:
try:
await upload(chunk)
finally:
await release_slot() # runs on success, failure and cancellation
Some work must finish even if the caller gives up - a payment that's already been sent, a write that's half done. Wrap it in asyncio.shield(): cancelling or timing out the waiter doesn't cancel the shielded task. In a test, wait_for(asyncio.shield(payment), 0.05) raised TimeoutError, the payment task wasn't cancelled, and it finished with 'paid' afterwards. Keep a reference to the shielded task, and make sure something eventually awaits it.
Which tool for which job?
| You want | Use |
|---|---|
| Run tasks together; if one fails, stop the rest | async with asyncio.TaskGroup() |
| Run everything to the end and inspect each result | gather(..., return_exceptions=True) |
Catch errors from a TaskGroup | except* SomeError |
| A deadline for several awaits | async with asyncio.timeout(seconds) |
| A time limit for one call | asyncio.wait_for(coro, seconds) |
| Cleanup that always runs | finally |
| Work that must finish even if the caller gives up | asyncio.shield(task) |
Common mistakes
| Mistake | What happens | Do this instead |
|---|---|---|
gather for "fail fast" | the other calls keep running | TaskGroup |
except ValueError around a TaskGroup | never matches - it's an ExceptionGroup | except* ValueError |
Catching CancelledError and carrying on | timeouts and shutdown silently fail | let it propagate; clean up in finally |
Bare except: or except BaseException: in a coroutine | swallows cancellation | except Exception: |
| Network calls with no time limit | a slow service hangs requests forever | asyncio.timeout |
| Cancelling a payment mid-flight | half-finished work | asyncio.shield |
Check yourself
Try to answer before opening each one. Recalling beats re-reading.
1. One of two calls in gather fails. What happens to the other one?
Show answer
It keeps running to the end, though nobody will read its result - gather passes on the first exception without cancelling the rest. A TaskGroup cancels the others.
2. Why doesn't except ValueError catch a failure from inside a TaskGroup?
Show answer
A TaskGroup raises an ExceptionGroup, even when only one task failed. Use except* ValueError, which matches the ValueErrors inside the group.
3. A coroutine catches asyncio.CancelledError and returns a default. What happens when it runs under asyncio.timeout(0.1) and is too slow?
Show answer
The timeout cancels it, the coroutine catches the cancellation and returns normally, and the block ends with no TimeoutError - the deadline silently does nothing. Let CancelledError propagate.
Key takeaways
gatherdoesn't stop the other calls when one fails;TaskGroupdoes, and never leaves tasks behind.- A
TaskGroupraises anExceptionGroup- catch it withexcept*. asyncio.timeoutandwait_forcancel the work and raise the built-inTimeoutError.CancelledErroris aBaseException: never swallow it; clean up infinally.shieldthe work that must finish.