Handling ExceptionGroup from TaskGroup¶
You migrated a concurrent fan-out from asyncio.gather to asyncio.TaskGroup to get fail-fast cancellation, and now your error handling is silently broken. The try/except ConnectionError that used to catch a downstream failure around gather no longer catches anything — the exception sails straight past it and crashes the request. Nothing changed in the children; what changed is that a TaskGroup never raises a bare exception. It raises an ExceptionGroup that wraps every non-cancellation failure, even when only one task failed, and a plain except clause does not match a group. This guide walks through reproducing the symptom and rewriting the handler with except* so single failures, multiple concurrent failures, unmatched types, and deadline timeouts are all handled correctly.
Prerequisites¶
- Python 3.11+ —
asyncio.TaskGroup, theexcept*syntax, and the built-inExceptionGroup/BaseExceptionGrouptypes are all 3.11 features. - Familiarity with the group's contract — when it cancels siblings and how errors are aggregated — from Exception Groups & TaskGroups and the broader Resilience, Cancellation & Error Handling overview.
- No third-party libraries; every snippet below is stdlib-only and runnable as-is.
Step 1: Reproduce — a plain except misses the group¶
Start by confirming the failure. The except ConnectionError looks correct but matches nothing, because what propagates is an ExceptionGroup containing a ConnectionError, not the error itself.
What to verify: the program does not print handled:. Instead it terminates with ExceptionGroup('unhandled errors in a TaskGroup', [ConnectionError('db down')]). That traceback header — "unhandled errors in a TaskGroup" — is the tell that you are dealing with a group, not a plain exception.
Step 2: Handle it with except* per exception type¶
Replace except with except*. Each clause receives a sub-group holding only the matching types; iterate .exceptions to reach the individual errors. The two forms cannot be mixed in one try statement — once you use except*, every clause on that statement must use it.
What to verify: the program now prints handled connection error: db down and exits cleanly with no traceback. The eg bound by except* is itself an ExceptionGroup; the leaves are in eg.exceptions.
Step 3: Handle multiple concurrent failures¶
The whole point of a group is that several children can fail in the same scheduling window. Make two tasks fail and confirm your handler sees both, not just the first — the bug a single-exception mindset hides.
What to verify: the output reports 2 connection failure(s) and lists both db down and queue/cache. If you only ever see one, you are reading a single exception somewhere instead of iterating eg.exceptions.
Step 4: Re-raise and propagate unhandled subgroups¶
If a child raises a type that no except* clause matches, Python automatically re-raises a residual group containing just the unmatched leaves. This is a safety feature, but you must plan for it — add a final except* Exception to log and decide, rather than letting an unexpected group escape.
What to verify: both clauses fire — connection: ['db down'] and other: ['ValueError']. Remove the second clause and the ValueError re-raises as a one-element ExceptionGroup, proving unmatched types are never silently dropped.
Step 5: Combine with a timeout (TimeoutError inside the group)¶
Bound the fan-out with asyncio.timeout(). A clean deadline raises a plain TimeoutError from the context manager, so handle it with a normal except. But if a child also fails before the deadline cancellation settles, a TimeoutError can appear inside the group — so cover both surfaces.
What to verify: the first call prints deadline exceeded; group cancelled (plain TimeoutError); the second prints child failed: ['b down'], because the child's exception is what aborts the group before the deadline fires. Keeping both a plain except TimeoutError and an except* set is what makes the handler robust to either ordering.
Verification¶
Confirm the migration is correct against these observable outcomes:
- All failures observed. With multiple failing children (Step 3), your logs show a count and one line per leaf, not a single error. Assert
len(eg.exceptions)equals the number you expect. - Matched subtypes handled. Each
except*clause runs at most once and only for its type; the program exits with code 0 when every raised type has a matching clause. - Unmatched types re-raised, never swallowed. Temporarily remove a clause and confirm the residual
ExceptionGrouppropagates with the unmatched leaves intact — silence here would be the bug. - Timeout path distinct. A pure deadline surfaces as a plain
TimeoutError; a child failure surfaces grouped. Both branches are reachable in tests.
Pitfalls & edge cases¶
- You cannot mix
exceptandexcept*in one statement.SyntaxErrorresults if you try. If you need to catch a non-group exception (like the plainTimeoutErrorfromasyncio.timeout) alongside grouped ones, that plain clause must itself beexcepton a statement with noexcept*, or restructure so the group handling is in a nestedtry. - Single vs nested groups. A child that is itself a
TaskGroupraises a group, which the parent nests inside its own —eg.exceptionsthen contains anotherExceptionGroup. Useeg.split(SomeError)or a recursive walk over.exceptionsto reach deeply nested leaves rather than assuming one level. CancelledErrorcan appear in the group. It is aBaseException, so it lands in aBaseExceptionGroup, andexcept* Exceptionwill not match it. If you must react to cancellation explicitly, useexcept* asyncio.CancelledError— but in most code you should let it propagate so the surrounding scope can tear down, per cancellation patterns..exceptionsis a tuple of the immediate children only. It is not flattened. For metrics or logging that must see every leaf regardless of depth, recurse, or calleg.split(predicate)which preserves the nesting in its matched/unmatched halves.- An
except* Tclause runs at most once even for many leaves of typeT. Do not put per-error side effects after the loop expecting them to run per exception — iterateeg.exceptionsinside the clause.
Frequently Asked Questions¶
Why doesn't except ConnectionError catch an error from a TaskGroup?
Because TaskGroup wraps every non-cancellation failure, even a single one, in an ExceptionGroup. A plain except ConnectionError does not match an ExceptionGroup containing a ConnectionError. Use except* ConnectionError, which extracts matching exceptions into a sub-group and runs your handler with them.
Can I use except and except* in the same try block?
No. Mixing them in one try statement raises SyntaxError. Once any clause uses except, all clauses on that statement must use except. To also catch a non-group exception, place it on a separate try statement, often a nested one.
How do I make sure I handle every error in a TaskGroup, not just the first?
Iterate eg.exceptions inside each except* clause rather than reading a single exception. For nested groups, where a child is itself a TaskGroup, recurse over .exceptions or use eg.split() so deeply nested leaves are also reached.
Related¶
- Exception Groups & TaskGroups — up to the full reference on group semantics, partial results, and combining groups with timeouts and semaphores.
- Cancellation patterns — why
CancelledErrorshould usually propagate and how to keep cleanup from swallowing it. - Resilience, Cancellation & Error Handling — the overview tying grouped errors into the wider resilience model.