Mapping ExceptionGroups to HTTP Errors¶
FastAPI and Starlette pick an exception handler by the exception's type. An endpoint that fans out with asyncio.TaskGroup raises an ExceptionGroup, not the error inside it — so every handler written for the individual errors stops matching. Measured on Python 3.14 with FastAPI 0.142, an app with handlers mapping NotFound to 404 and UpstreamDown to 503: calling an endpoint that raised those errors directly returned 404 and 503. Calling a fan-out endpoint where one of two tasks raised the same errors returned 500 Internal Server Error in all four cases tested — including a plain ValueError meant to be a client error. Registering a single handler for ExceptionGroup, which flattens the group and maps its contents by a priority list, restored 404, 503 and 422, and for a group containing both a NotFound and an UpstreamDown returned 404 with a body saying it was 1 of 2 errors. This guide builds that handler and the rules behind it.
Prerequisites¶
- FastAPI or Starlette, Python 3.11+.
- Exception groups, from handling ExceptionGroup from TaskGroup.
- The topic overview, Exception Groups & TaskGroups.
1. See the handlers stop matching¶
An app with handlers for its domain errors, and two endpoints — one calling a dependency directly, one fanning out to two:
@app.exception_handler(NotFound)
async def not_found(request, exc):
return JSONResponse({"error": "not found", "detail": str(exc)}, status_code=404)
@app.get("/fanout/{a}/{b}")
async def fanout(a: str, b: str):
async with asyncio.TaskGroup() as tg:
ta = tg.create_task(fetch(a))
tb = tg.create_task(fetch(b))
return {"a": ta.result(), "b": tb.result()}
Measured: /direct/missing returned 404 and /direct/down 503, as designed. /fanout/ok/missing, /fanout/ok/down, /fanout/missing/down and /fanout/bad/ok all returned 500 Internal Server Error. The NotFound was still there, one level down inside an ExceptionGroup, and the framework looked up handlers for ExceptionGroup, found none, and treated it as an unhandled error. Clients that relied on a 404 to stop retrying now retry; monitoring that alerts on 5xx now alerts on missing records.
Verify: for each endpoint that uses a TaskGroup, a test triggers each domain error inside the group and checks the status code.
2. Flatten the group¶
Groups can nest — a TaskGroup inside a task of another TaskGroup produces a group inside a group — so mapping starts by collecting the leaf exceptions:
def leaves(group: BaseExceptionGroup):
for exc in group.exceptions:
if isinstance(exc, BaseExceptionGroup):
yield from leaves(exc)
else:
yield exc
Measured with the two-task fan-out where both dependencies failed at the same moment: the group held two leaves, a NotFound and an UpstreamDown, because both tasks failed before either could be cancelled. A handler that looked only at group.exceptions[0] would answer according to whichever failure happened to be recorded first. For deeper nesting, see flattening nested ExceptionGroups.
Verify: a test with nested groups produces the same leaf list as a flat group with the same errors.
3. Map by an explicit priority¶
When a group holds several kinds of error, the response can only have one status. Decide the rule once, in one place:
PRIORITY = [
(NotFound, 404), # client-side facts first: retrying will not help
(ValueError, 422),
(PermissionError, 403),
(UpstreamDown, 503), # then retryable server-side conditions
]
@app.exception_handler(ExceptionGroup)
async def exception_group_handler(request: Request, group: ExceptionGroup):
found = list(leaves(group))
for exc_type, status in PRIORITY:
matched = [e for e in found if isinstance(e, exc_type)]
if matched:
return JSONResponse(
{"error": exc_type.__name__, "count": len(matched), "of": len(found)},
status_code=status,
)
log.error("unmapped exception group", exc_info=group)
return JSONResponse({"error": "internal"}, status_code=500)
Measured: the four fan-out cases returned 404, 503, 404 and 422. For the mixed group, NotFound won over UpstreamDown, and the body reported "count": 1, "of": 2. The reasoning behind putting client errors first: if a record does not exist, the request will fail even after the upstream recovers, so a 404 tells the client not to retry. Teams that prefer to surface outages first can reverse the order; what matters is that the rule is explicit and tested.
Verify: a table-driven test covers each error type alone and each pair, and asserts the chosen status.
4. Or unwrap single errors at the endpoint¶
When an endpoint's group almost always holds one error, re-raising that error lets every existing handler apply unchanged:
@app.get("/fanout/{a}/{b}")
async def fanout(a: str, b: str):
try:
async with asyncio.TaskGroup() as tg:
ta = tg.create_task(fetch(a))
tb = tg.create_task(fetch(b))
except* Exception as group:
found = list(leaves(group))
if len(found) == 1:
raise found[0] # existing NotFound/UpstreamDown handlers apply
raise
return {"a": ta.result(), "b": tb.result()}
This keeps handlers simple, but it decides the multi-error case by falling through to the group handler anyway, so the step 3 handler is still needed as a backstop. Re-raising a leaf from inside except* adds the group as its context in the traceback, which keeps the full story in logs.
Verify: a single error inside the group produces the same response as the same error raised directly.
5. Keep error detail out of responses, in logs¶
The response should tell the client what it needs — the status, an error code, perhaps how many sub-requests failed — and nothing about internal services. Log the whole group for operators:
@app.exception_handler(ExceptionGroup)
async def exception_group_handler(request, group):
found = list(leaves(group))
log.warning("fan-out failed", extra={
"path": request.url.path,
"errors": [f"{type(e).__name__}: {e}" for e in found],
}, exc_info=group)
...
The leaves carry their notes and tracebacks, so the log shows which sub-request failed and why, as in adding notes to exceptions in async code, while the client sees {"error": "NotFound", "count": 1, "of": 2}. Exclude CancelledError from mapping: a cancelled request has no client waiting for the response.
Verify: responses contain no internal hostnames or exception messages from dependencies, and logs contain every leaf.
Verification¶
Exception groups map to HTTP responses correctly when:
- An
ExceptionGrouphandler exists, so no domain error inside a group becomes a 500. - Groups are flattened before mapping.
- A single explicit priority list decides mixed groups, with tests for each pair.
- Responses are minimal, and logs carry every leaf with its notes.
Diagnostic Hook: when 500s appear on an endpoint after it was changed to fetch in parallel, look at the exception type in the server log. If it is ExceptionGroup wrapping a NotFound or a validation error, the per-type handlers no longer match; in this test every such case returned 500.
Pitfalls & edge cases¶
- Relying on per-type handlers with TaskGroups. Measured: 4 of 4 fan-out errors became 500.
- Mapping only the first exception. Concurrent failures produced groups of 2.
- Implicit ordering for mixed groups. Write the priority list down and test it.
- Leaking upstream messages to clients. Keep them in logs.
Frequently Asked Questions¶
Why does FastAPI return 500 for my custom exception inside a TaskGroup?
The endpoint raises an ExceptionGroup, and handlers match by type, so the NotFound handler is not used. All four fan-out cases returned 500 until an ExceptionGroup handler was added.
How do I handle ExceptionGroup in FastAPI?
Register @app.exception_handler(ExceptionGroup), flatten nested groups, and map leaf types to statuses with a priority list. That restored 404, 503 and 422.
Which status should a request with several different errors return?
One chosen by an explicit rule. Here client errors came first: NotFound plus UpstreamDown returned 404, with a body saying it was 1 of 2 errors.
Can I re-raise the exception from inside a group?
Yes: in except*, if the group has one leaf, raise it and existing handlers apply. Keep a group handler for multi-error cases.
Related¶
- Exception Groups & TaskGroups — up to the topic overview.
- Collecting errors without cancelling siblings — groups built on purpose.
- Resilience, Cancellation & Error Handling — the section overview.