Skip to content

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

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.

Status codes, direct call vs TaskGroup fan-out A grid of 6 rows by 3 columns. Status codes, direct call vs TaskGroup fan-out request default handlers with ExceptionGroup handler direct NotFound 404 404 direct UpstreamDown 503 503 fan-out, one task NotFound 500 404 fan-out, one task UpstreamDown 500 503 fan-out, NotFound + UpstreamDown 500 404 (1 of 2 errors) fan-out, ValueError 500 422 FastAPI 0.142.2, Python 3.14.

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.

From a failed fan-out to one response A flow of 5 stages. From a failed fan-out to one response TaskGroup raises ExceptionGroup Flatten nested groups to leaves Priority list first matching type wins Respond status + count of N errors Unmapped log it, return 500 One handler, one rule, tested once.

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.

Fan-out requests answered with 500 2 horizontal bars comparing per-type handlers only with the others. Fan-out requests answered with 500 per-type handlers only 4 of 4 plus an ExceptionGroup handler 0 of 4 Every domain error inside a group had become a 500.

Verification

Exception groups map to HTTP responses correctly when:

  • An ExceptionGroup handler 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.