Elegant per-endpoint error handling for FastAPI that keeps OpenAPI in sync
Project description
fastapi-error-map
Elegant per-endpoint error handling for FastAPI that keeps OpenAPI in sync.
Declare on the route how exceptions become HTTP responses, and the OpenAPI error schema is generated from that same declaration. Error handling and schema can't drift — they are one source.
Install
pip install fastapi-error-map
Requires Python 3.10+ and FastAPI 0.100+.
Quickstart
Two steps:
- Swap
APIRouterforErrorAwareRouter. - Declare
error_mapon the route.
from fastapi import FastAPI
from pydantic import BaseModel
from fastapi_error_map import ErrorAwareRouter, rule
class AuthenticationError(Exception): ...
class UserNotFoundError(Exception): ...
class Stock(BaseModel):
available: int
def notify(err: Exception) -> None:
print(f"lookup failed: {err}")
router = ErrorAwareRouter()
@router.get(
"/stock/",
error_map={
# Short form: map exception to status.
AuthenticationError: 401,
# Full form: rule() adds side effect (and headers, OpenAPI docs, ...).
UserNotFoundError: rule(404, on_error=notify),
},
)
def check_stock(user_id: int = 0) -> Stock:
if user_id == 0:
raise AuthenticationError("authentication required")
raise UserNotFoundError(f"user {user_id} not found")
app = FastAPI()
app.include_router(router)
The handler raises. The router maps each exception to its status and body:
GET /stock/→401 {"error": "authentication required"}GET /stock/?user_id=1→404 {"error": "user 1 not found"}
The same map drives OpenAPI schema — 401 and 404 appear under the route, no responses= to
maintain by hand:
Figure 1: error responses generated from the map.
Full runnable file: examples/readme_quickstart.py. For the bare
minimum — one exception, one status — see examples/quickstart.py.
Why not global handlers?
To turn application errors into HTTP responses, FastAPI lets you attach a global handler:
app.add_exception_handler(UserNotFoundError, handle_user_not_found)
It works at runtime, and quietly costs you two things.
First, you cannot see it at the route. The handler lives elsewhere, so the route never shows which
errors it returns — and neither does OpenAPI: the schema lists 200 and 422, never the 404 you
actually send. Your Swagger is wrong the moment you add a handler.
Second, one type maps to one response — for every route. But exception meaning is local.
UserNotFoundError is 404 in a lookup, yet 401 behind authentication, where a missing user means
"access denied", not "no such resource". A global handler sees the type, not the context, so it maps
both the same.
Common local workarounds:
try/exceptin the route repeats mapping logic across handlers, clutters the view, and stays invisible to OpenAPI — FastAPI cannot read yourexceptblocks back into the schema.- Manual
responses=documents the error in a second place. Nothing keeps it in step with the route; the schema drifts on the next change, with no warning.
Neither gives accurate behavior and accurate schema. The map gives both from one declaration.
What you get
- Per-route mapping. Plain dict, or
rule(...)when status is not enough — to set body, headers, side effect, or to enrich the OpenAPI entry with description and examples. - OpenAPI from the same map. Every mapped error lands in the schema — no
responses=to maintain by hand, though one you pass yourself still wins on its status. - Standard envelope, without writing one.
structured()returns{code, message, details}from your exception's attributes. - Custom formats are plain callables. Any
Callable[[Exception], T]; its return annotation becomes the schema model. - Built-in envelopes keep 5xx opaque.
simple()andstructured()hide server detail by default;structured()can expose chosen types. - Plays fair with FastAPI.
HTTPExceptionand request validation pass through untouched. Unmapped exceptions are re-raised with their original type and traceback, and logged by default so gaps in the map stay visible. - Dependencies covered. Exceptions from
Depends()(auth, quotas) are mapped by the same route. - Headers are part of the contract.
Retry-After,WWW-Authenticatedeclared in the rule, sent at runtime, shown in OpenAPI. - Service-wide policy in one place. Envelope and callbacks set once on the router; each route declares only what is specific to it.
- Mistakes surface early. A translator's return type is the schema model — mypy flags the mismatch; bad config fails at startup, naming the route.
- Fits existing codebases. Drop in
ErrorAwareRouter, or keep yourAPIRouterand use@error_map.
error_map: short and full form
error_map maps each exception type to a status, or to a rule(). Statuses must be 4xx or 5xx —
anything else fails at startup with RouteConfigError.
The short form maps to a status:
error_map = {SomeError: 404}
It is exactly the full form with defaults:
error_map = {SomeError: rule(404)}
Both use the default simple() translator: {"error": str(err)} for 4xx, opaque message for 5xx.
Reach for rule(...) when status is not enough — for custom body, headers, side effect, or
richer OpenAPI.
Resolution (MRO)
An exception is matched along its method resolution order: the most specific mapped type wins, then
parents in MRO order. Map BaseError and raise ChildError(BaseError) → the BaseError rule applies,
unless ChildError is mapped too, in which case it takes precedence.
rule()
def rule(
status: int,
*,
translator: Translator[T] | None = None,
headers: Headers | None = None,
on_error: OnError | None = None,
openapi_model: type[T] | None = None,
openapi_description: str | None = None,
openapi_examples: dict[str, Any] | None = None,
) -> Rule: ...
status— HTTP status to return (4xx or 5xx).translator—Callable[[Exception], T]building the response body. Its return annotation becomes the OpenAPI model. Defaults to the router's translator. A translator that raises is not caught and surfaces as500(unlikeon_error), so handle every exception it may receive — withstructured(), guard attributes the exception may lack.headers— staticMapping(introspected into OpenAPI) or callable(err) -> Mapping[str, str](resolved per request, not introspected). The callable shapes the response, so it must return a mapping and not raise. Values reach the client verbatim on every status, 5xx included, so put only safe-to-expose data here. CustomContent-Type(e.g.application/problem+json) goes here too.on_error— side effect for observability: logging, metrics, alerting. Sync or async, runs inline. If it raises, the failure is logged and the mapped response is still sent — a broken side effect leaves the response intact.openapi_model— schema model, when the translator has no return annotation (lambda, or-> None) or to override inference.openapi_description,openapi_examples— documentation for the response.
on_error is awaited before the response, so keep it light — it adds to response latency. A blocking
sync call (sync HTTP, disk) stalls the loop for other requests; mark it with to_threadpool to
run it off the loop:
from fastapi_error_map import to_threadpool
rule(503, on_error=to_threadpool(write_audit_log))
This offloads the loop, not the wait — the response still waits for the callback. To answer without waiting, schedule the work (task, queue) and return.
One rule carrying header, callback, and documented body:
RateLimitedError: rule(
429,
translator=to_body,
headers=retry_after_header, # (err) -> {"Retry-After": ...}
on_error=log_rate_limit,
openapi_description="Per-client report quota exhausted.",
)
Runnable: examples/extended_rule.py.
Built-in envelopes: simple() and structured()
A translator factory turns a body format into a per-route translator. Two are built in; both keep 5xx opaque so server internals never reach the client.
simple() — the default. {"error": str(err)} for 4xx, opaque message for 5xx. Reads only
str(err), so it works on any exception. Rarely written out:
router = ErrorAwareRouter(translator_factory=simple())
structured() — {code, message, details} envelope:
router = ErrorAwareRouter(translator_factory=structured())
By default it reads err.code, str(err), and err.details. Missing, empty, or non-string code
falls back to the status name ("HTTP_404_NOT_FOUND"). Absent message/details keys are omitted,
never null.
When your exceptions carry that data under other names, point each field at its attribute — explicitly, nothing is guessed:
structured(
code=lambda err: err.error_code,
message=lambda err: err.reason,
details=lambda err: err.context,
)
5xx stays opaque: message becomes server_message, never str(err). Whitelist types to render in
full with exposed_5xx_types. Opacity is body-only — headers are still sent as declared.
Runnable: examples/structured_envelope.py,
examples/custom_fields.py.
Custom envelope
Need a different envelope entirely? A TranslatorFactory is a function returning a function —
(status) -> (err) -> body. simple() and structured() are built exactly this way.
class ProblemDetail(TypedDict):
type: str
title: str
status: int
detail: str
def problem_detail(status_code: int) -> Callable[[Exception], ProblemDetail]:
title = HTTPStatus(status_code).phrase
def translate(err: Exception) -> ProblemDetail:
return ProblemDetail(
type="about:blank", title=title, status=status_code, detail=str(err),
)
return translate
router = ErrorAwareRouter(translator_factory=problem_detail)
The model is inferred from the inner translator's return annotation; pass openapi_model= on the rule
when there is none. A custom factory owns its output fully — 5xx opacity is yours to keep, so guard
str(err) at 5xx if the body might carry server detail.
This is also how you get RFC 9457 problem+json — the body above, plus its content type on the rule:
ForbiddenError: rule(403, headers={"Content-Type": "application/problem+json"})
The runtime response carries application/problem+json. The OpenAPI schema documents the model under
application/json — FastAPI binds a response model to that content key, so the schema describes the
body's shape there regardless of the wire content type.
Runnable: examples/custom_factory.py.
FastAPI interop and router-level policy
Two ways to adopt the map.
Drop-in. ErrorAwareRouter replaces APIRouter. Router-level arguments set policy for every route;
per-route error_map declares the specifics:
router = ErrorAwareRouter(
translator_factory=structured(), # envelope for all routes
on_error=report, # default side effect
warn_on_unmapped=True, # log exceptions not in any map (default)
)
Policy belongs to the router that declares the route. It is decided at that point and does not change
later. include_router keeps those routes as they are, so the map still works when you nest routers.
But policy is not inherited, unlike dependencies or tags. A child router gets nothing from its
parent. If the parent maps {ServerError: 503} and a ServerError is raised in a child router, it is
not caught: it stays unhandled. This is FastAPI's limit, not our choice.
include_router copies the child routes instead of linking them, and it does not know about our
error_map, so FastAPI gives us no way to pass policy down. For now, to use the same policy in many
modules, you have to set it on each router yourself: keep the shared error_map and settings in one
place, and pass them to every router that needs them.
Without replacing your router. Keep your own APIRouter; give it our route_class (interception
point) and put @error_map on the endpoint (the map). Both parts are required; any custom route_class
must subclass ErrorAwareRoute:
router = APIRouter(route_class=ErrorAwareRoute)
@router.get("/accounts/{account_id}/")
@error_map({ForbiddenError: 403})
def get_account(account_id: int) -> Account: ...
Runnable: examples/interop.py.
Pass-through and unmapped exceptions
HTTPException and RequestValidationError are rendered by FastAPI, never by the map — even under a
broad {Exception: 500}. On ErrorAwareRouter, mapping one of them has no effect and warns with
ErrorMapWarning at declaration; mapping 422 warns too, as it shadows request validation — give
your own validation error a different 4xx, such as 400. The @error_map interop path behaves
identically — framework exception passes through, 422 is shadowed — but emits no warning.
Unmapped exceptions are re-raised with their original type and traceback, reaching your global
@app.exception_handler(...) unchanged. warn_on_unmapped (on by default) logs each one first, so a
missing entry surfaces in the logs; it controls only that warning, never the re-raise. Passing
error_map to a WebSocket route fails at startup; mapping wraps HTTP only.
OpenAPI generation
From the map, the schema picks up automatically: status codes, the response model (translator return
annotation, or openapi_model), static header names, and any openapi_description /
openapi_examples. Several exceptions on one status become an anyOf union. Callable headers are
per-request, so they are not introspected. Any responses= you pass yourself wins on a status it shares
with the map.
The schema is read from the same declaration that handles the error (Figure 1) — one source, no drift.
Examples
Each file is runnable with python -m examples.<name>:
quickstart.py— one exception, one status.readme_quickstart.py— the Quickstart above, end to end.structured_envelope.py—structured()with 5xx kept opaque.custom_fields.py—structured()reading your own attribute names.custom_factory.py— custom envelope (RFC 9457problem+json).extended_rule.py— onerule()with header, side effect, and docs.interop.py— adoption viaroute_class+@error_map.
For a larger application, see the Clean Architecture example app.
License
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file fastapi_error_map-1.0.0.tar.gz.
File metadata
- Download URL: fastapi_error_map-1.0.0.tar.gz
- Upload date:
- Size: 337.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.24 {"installer":{"name":"uv","version":"0.11.24","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7799954314684b7106e326d7a0c2c2ba362fdfea0ddff7ec3e089dc10946744f
|
|
| MD5 |
5cd01a43983bb9dd9b7eee533de469ad
|
|
| BLAKE2b-256 |
2db7275fdf248a4bd67f66c77200f5db039d6073b319f7724db7444afa68f593
|
File details
Details for the file fastapi_error_map-1.0.0-py3-none-any.whl.
File metadata
- Download URL: fastapi_error_map-1.0.0-py3-none-any.whl
- Upload date:
- Size: 25.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.24 {"installer":{"name":"uv","version":"0.11.24","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bf8e2d6c8c43c1db96b28bc5eb34431576c176a2c35754c659fe88e17f26d7fe
|
|
| MD5 |
6f0bddd41e87847ab5396274bdbba102
|
|
| BLAKE2b-256 |
444f071b43fc51580ed22e6cbf4f77a171be2d5866812c36e0bb007dcd4bbf4e
|