fastapi-error-map 0.9.10__tar.gz → 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastapi_error_map-0.9.10 → fastapi_error_map-1.0.0}/.github/workflows/ci.yaml +4 -2
- {fastapi_error_map-0.9.10 → fastapi_error_map-1.0.0}/.github/workflows/publish.yaml +1 -1
- {fastapi_error_map-0.9.10 → fastapi_error_map-1.0.0}/.github/workflows/test-compatibility.yaml +0 -1
- {fastapi_error_map-0.9.10 → fastapi_error_map-1.0.0}/.gitignore +3 -0
- fastapi_error_map-1.0.0/.python-version +1 -0
- {fastapi_error_map-0.9.10 → fastapi_error_map-1.0.0}/Makefile +4 -3
- fastapi_error_map-1.0.0/PKG-INFO +414 -0
- fastapi_error_map-1.0.0/README.md +388 -0
- fastapi_error_map-1.0.0/docs/example-openapi.png +0 -0
- fastapi_error_map-1.0.0/examples/custom_factory.py +80 -0
- fastapi_error_map-1.0.0/examples/custom_fields.py +57 -0
- fastapi_error_map-1.0.0/examples/extended_rule.py +76 -0
- fastapi_error_map-1.0.0/examples/interop.py +43 -0
- fastapi_error_map-1.0.0/examples/quickstart.py +45 -0
- fastapi_error_map-1.0.0/examples/readme_quickstart.py +55 -0
- fastapi_error_map-1.0.0/examples/structured_envelope.py +58 -0
- fastapi_error_map-1.0.0/pyproject.toml +190 -0
- fastapi_error_map-1.0.0/src/fastapi_error_map/__init__.py +44 -0
- fastapi_error_map-1.0.0/src/fastapi_error_map/concurrency.py +54 -0
- fastapi_error_map-1.0.0/src/fastapi_error_map/framework.py +19 -0
- fastapi_error_map-1.0.0/src/fastapi_error_map/handler.py +93 -0
- fastapi_error_map-1.0.0/src/fastapi_error_map/http_status.py +17 -0
- fastapi_error_map-1.0.0/src/fastapi_error_map/openapi.py +68 -0
- fastapi_error_map-1.0.0/src/fastapi_error_map/route_config.py +68 -0
- fastapi_error_map-1.0.0/src/fastapi_error_map/routing.py +226 -0
- fastapi_error_map-1.0.0/src/fastapi_error_map/rules.py +230 -0
- fastapi_error_map-1.0.0/src/fastapi_error_map/translator_factories/__init__.py +19 -0
- fastapi_error_map-1.0.0/src/fastapi_error_map/translator_factories/common.py +3 -0
- fastapi_error_map-1.0.0/src/fastapi_error_map/translator_factories/simple.py +44 -0
- fastapi_error_map-1.0.0/src/fastapi_error_map/translator_factories/structured.py +119 -0
- fastapi_error_map-1.0.0/src/fastapi_error_map/types_.py +25 -0
- fastapi_error_map-1.0.0/tests/factories.py +59 -0
- fastapi_error_map-1.0.0/tests/test_build_errors.py +73 -0
- fastapi_error_map-1.0.0/tests/test_concurrency.py +189 -0
- fastapi_error_map-1.0.0/tests/test_examples.py +149 -0
- fastapi_error_map-1.0.0/tests/test_handling.py +392 -0
- fastapi_error_map-1.0.0/tests/test_headers.py +127 -0
- fastapi_error_map-1.0.0/tests/test_openapi.py +432 -0
- fastapi_error_map-1.0.0/tests/test_route_config.py +55 -0
- fastapi_error_map-1.0.0/tests/test_routing.py +336 -0
- fastapi_error_map-1.0.0/tests/test_translators.py +363 -0
- fastapi_error_map-1.0.0/tests/typing_cases/schema_runtime.py +30 -0
- {fastapi_error_map-0.9.10 → fastapi_error_map-1.0.0}/uv.lock +317 -362
- fastapi_error_map-0.9.10/PKG-INFO +0 -351
- fastapi_error_map-0.9.10/README.md +0 -324
- fastapi_error_map-0.9.10/docs/example-openapi.png +0 -0
- fastapi_error_map-0.9.10/examples/errors.py +0 -31
- fastapi_error_map-0.9.10/examples/main.py +0 -53
- fastapi_error_map-0.9.10/fastapi_error_map/__init__.py +0 -10
- fastapi_error_map-0.9.10/fastapi_error_map/error_handling.py +0 -85
- fastapi_error_map-0.9.10/fastapi_error_map/openapi.py +0 -39
- fastapi_error_map-0.9.10/fastapi_error_map/routing.py +0 -2660
- fastapi_error_map-0.9.10/fastapi_error_map/rules.py +0 -98
- fastapi_error_map-0.9.10/fastapi_error_map/translator_policy.py +0 -29
- fastapi_error_map-0.9.10/fastapi_error_map/translators.py +0 -75
- fastapi_error_map-0.9.10/pyproject.toml +0 -193
- fastapi_error_map-0.9.10/tests/integration/test_example.py +0 -40
- fastapi_error_map-0.9.10/tests/integration/test_exclude_none.py +0 -85
- fastapi_error_map-0.9.10/tests/integration/test_routing.py +0 -96
- fastapi_error_map-0.9.10/tests/integration/test_threadpool.py +0 -76
- fastapi_error_map-0.9.10/tests/unit/__init__.py +0 -0
- fastapi_error_map-0.9.10/tests/unit/error_stubs.py +0 -10
- fastapi_error_map-0.9.10/tests/unit/test_error_handling.py +0 -232
- fastapi_error_map-0.9.10/tests/unit/test_openapi.py +0 -85
- fastapi_error_map-0.9.10/tests/unit/test_routing.py +0 -44
- fastapi_error_map-0.9.10/tests/unit/test_rules.py +0 -174
- fastapi_error_map-0.9.10/tests/unit/test_translators.py +0 -43
- fastapi_error_map-0.9.10/tests/unit/translator_stubs.py +0 -40
- {fastapi_error_map-0.9.10 → fastapi_error_map-1.0.0}/.pre-commit-config.yaml +0 -0
- {fastapi_error_map-0.9.10 → fastapi_error_map-1.0.0}/LICENSE +0 -0
- {fastapi_error_map-0.9.10 → fastapi_error_map-1.0.0}/examples/__init__.py +0 -0
- {fastapi_error_map-0.9.10 → fastapi_error_map-1.0.0}/noxfile.py +0 -0
- {fastapi_error_map-0.9.10 → fastapi_error_map-1.0.0/src}/fastapi_error_map/py.typed +0 -0
- {fastapi_error_map-0.9.10 → fastapi_error_map-1.0.0}/tests/__init__.py +0 -0
- {fastapi_error_map-0.9.10/tests/integration → fastapi_error_map-1.0.0/tests}/conftest.py +0 -0
- {fastapi_error_map-0.9.10/tests/integration → fastapi_error_map-1.0.0/tests/typing_cases}/__init__.py +0 -0
|
@@ -13,7 +13,7 @@ jobs:
|
|
|
13
13
|
- name: Set up Python
|
|
14
14
|
uses: actions/setup-python@v6
|
|
15
15
|
with:
|
|
16
|
-
python-version: "3.
|
|
16
|
+
python-version: "3.10"
|
|
17
17
|
|
|
18
18
|
- name: Install uv
|
|
19
19
|
uses: astral-sh/setup-uv@v7
|
|
@@ -25,7 +25,9 @@ jobs:
|
|
|
25
25
|
run: uv run make lint
|
|
26
26
|
|
|
27
27
|
- name: Run tests for Codecov
|
|
28
|
-
run:
|
|
28
|
+
run: |
|
|
29
|
+
uv run coverage run -m pytest
|
|
30
|
+
uv run coverage xml
|
|
29
31
|
|
|
30
32
|
- name: Upload coverage reports to Codecov
|
|
31
33
|
uses: codecov/codecov-action@v5
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.10
|
|
@@ -7,12 +7,13 @@ MAKEFLAGS += --no-print-directory
|
|
|
7
7
|
lint:
|
|
8
8
|
ruff check --fix
|
|
9
9
|
ruff format
|
|
10
|
+
tombi format
|
|
11
|
+
tombi lint
|
|
10
12
|
mypy
|
|
11
13
|
|
|
12
14
|
test:
|
|
13
|
-
pytest -v
|
|
14
|
-
|
|
15
|
-
--cov-report=term-missing
|
|
15
|
+
coverage run -m pytest -v
|
|
16
|
+
coverage report --show-missing
|
|
16
17
|
|
|
17
18
|
check: lint test
|
|
18
19
|
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi-error-map
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Elegant per-endpoint error handling for FastAPI that keeps OpenAPI in sync
|
|
5
|
+
Project-URL: Homepage, https://github.com/ivan-borovets/fastapi-error-map
|
|
6
|
+
Project-URL: Repository, https://github.com/ivan-borovets/fastapi-error-map
|
|
7
|
+
Author-email: Ivan Borovets <ivan.r.borovets@gmail.com>
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Framework :: FastAPI
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: fastapi<1.0,>=0.100
|
|
24
|
+
Requires-Dist: typing-extensions>=4.1
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# fastapi-error-map
|
|
28
|
+
|
|
29
|
+
[](https://badge.fury.io/py/fastapi-error-map)
|
|
30
|
+

|
|
31
|
+
[](https://codecov.io/gh/ivan-borovets/fastapi-error-map)
|
|
32
|
+

|
|
33
|
+
[](https://github.com/ivan-borovets/fastapi-error-map/actions)
|
|
34
|
+
|
|
35
|
+
Elegant per-endpoint error handling for FastAPI that keeps OpenAPI in sync.
|
|
36
|
+
|
|
37
|
+
Declare **on the route** how exceptions become HTTP responses, and the OpenAPI error schema is generated
|
|
38
|
+
from that same declaration. Error handling and schema can't drift — they are one source.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install fastapi-error-map
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Requires Python 3.10+ and FastAPI 0.100+.
|
|
47
|
+
|
|
48
|
+
## Quickstart
|
|
49
|
+
|
|
50
|
+
Two steps:
|
|
51
|
+
|
|
52
|
+
1. Swap `APIRouter` for `ErrorAwareRouter`.
|
|
53
|
+
2. Declare `error_map` on the route.
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from fastapi import FastAPI
|
|
57
|
+
from pydantic import BaseModel
|
|
58
|
+
|
|
59
|
+
from fastapi_error_map import ErrorAwareRouter, rule
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AuthenticationError(Exception): ...
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class UserNotFoundError(Exception): ...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Stock(BaseModel):
|
|
69
|
+
available: int
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def notify(err: Exception) -> None:
|
|
73
|
+
print(f"lookup failed: {err}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
router = ErrorAwareRouter()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@router.get(
|
|
80
|
+
"/stock/",
|
|
81
|
+
error_map={
|
|
82
|
+
# Short form: map exception to status.
|
|
83
|
+
AuthenticationError: 401,
|
|
84
|
+
# Full form: rule() adds side effect (and headers, OpenAPI docs, ...).
|
|
85
|
+
UserNotFoundError: rule(404, on_error=notify),
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
def check_stock(user_id: int = 0) -> Stock:
|
|
89
|
+
if user_id == 0:
|
|
90
|
+
raise AuthenticationError("authentication required")
|
|
91
|
+
raise UserNotFoundError(f"user {user_id} not found")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
app = FastAPI()
|
|
95
|
+
app.include_router(router)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The handler raises. The router maps each exception to its status and body:
|
|
99
|
+
|
|
100
|
+
- `GET /stock/` → `401 {"error": "authentication required"}`
|
|
101
|
+
- `GET /stock/?user_id=1` → `404 {"error": "user 1 not found"}`
|
|
102
|
+
|
|
103
|
+
The same map drives OpenAPI schema — `401` and `404` appear under the route, no `responses=` to
|
|
104
|
+
maintain by hand:
|
|
105
|
+
|
|
106
|
+
<div align="center">
|
|
107
|
+
<img src="docs/example-openapi.png" alt="Generated OpenAPI error responses" width="600"/>
|
|
108
|
+
<p><em>Figure 1: error responses generated from the map.</em></p>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
Full runnable file: [`examples/readme_quickstart.py`](examples/readme_quickstart.py). For the bare
|
|
112
|
+
minimum — one exception, one status — see [`examples/quickstart.py`](examples/quickstart.py).
|
|
113
|
+
|
|
114
|
+
## Why not global handlers?
|
|
115
|
+
|
|
116
|
+
To turn application errors into HTTP responses, FastAPI lets you attach a global handler:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
app.add_exception_handler(UserNotFoundError, handle_user_not_found)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
It works at runtime, and quietly costs you two things.
|
|
123
|
+
|
|
124
|
+
First, you cannot see it at the route. The handler lives elsewhere, so the route never shows which
|
|
125
|
+
errors it returns — and neither does OpenAPI: the schema lists `200` and `422`, never the `404` you
|
|
126
|
+
actually send. Your Swagger is wrong the moment you add a handler.
|
|
127
|
+
|
|
128
|
+
Second, one type maps to one response — for every route. But exception meaning is local.
|
|
129
|
+
`UserNotFoundError` is `404` in a lookup, yet `401` behind authentication, where a missing user means
|
|
130
|
+
"access denied", not "no such resource". A global handler sees the type, not the context, so it maps
|
|
131
|
+
both the same.
|
|
132
|
+
|
|
133
|
+
Common local workarounds:
|
|
134
|
+
|
|
135
|
+
- `try/except` in the route repeats mapping logic across handlers, clutters the view, and stays
|
|
136
|
+
invisible to OpenAPI — FastAPI cannot read your `except` blocks back into the schema.
|
|
137
|
+
- Manual `responses=` documents the error in a second place. Nothing keeps it in step with the
|
|
138
|
+
route; the schema drifts on the next change, with no warning.
|
|
139
|
+
|
|
140
|
+
Neither gives accurate behavior and accurate schema. The map gives both from one declaration.
|
|
141
|
+
|
|
142
|
+
## What you get
|
|
143
|
+
|
|
144
|
+
- **Per-route mapping.** Plain dict, or `rule(...)` when status is not enough — to set body,
|
|
145
|
+
headers, side effect, or to enrich the OpenAPI entry with description and examples.
|
|
146
|
+
- **OpenAPI from the same map.** Every mapped error lands in the schema — no `responses=` to maintain
|
|
147
|
+
by hand, though one you pass yourself still wins on its status.
|
|
148
|
+
- **Standard envelope, without writing one.** `structured()` returns `{code, message, details}`
|
|
149
|
+
from your exception's attributes.
|
|
150
|
+
- **Custom formats are plain callables.** Any `Callable[[Exception], T]`; its return annotation
|
|
151
|
+
becomes the schema model.
|
|
152
|
+
- **Built-in envelopes keep 5xx opaque.** `simple()` and `structured()` hide server detail by default;
|
|
153
|
+
`structured()` can expose chosen types.
|
|
154
|
+
- **Plays fair with FastAPI.** `HTTPException` and request validation pass through untouched. Unmapped
|
|
155
|
+
exceptions are re-raised with their original type and traceback, and logged by default so gaps in
|
|
156
|
+
the map stay visible.
|
|
157
|
+
- **Dependencies covered.** Exceptions from `Depends()` (auth, quotas) are mapped by the same route.
|
|
158
|
+
- **Headers are part of the contract.** `Retry-After`, `WWW-Authenticate` declared in the rule, sent
|
|
159
|
+
at runtime, shown in OpenAPI.
|
|
160
|
+
- **Service-wide policy in one place.** Envelope and callbacks set once on the router; each route
|
|
161
|
+
declares only what is specific to it.
|
|
162
|
+
- **Mistakes surface early.** A translator's return type is the schema model — mypy flags the
|
|
163
|
+
mismatch; bad config fails at startup, naming the route.
|
|
164
|
+
- **Fits existing codebases.** Drop in `ErrorAwareRouter`, or keep your `APIRouter` and use
|
|
165
|
+
`@error_map`.
|
|
166
|
+
|
|
167
|
+
## error_map: short and full form
|
|
168
|
+
|
|
169
|
+
`error_map` maps each exception type to a status, or to a `rule()`. Statuses must be 4xx or 5xx —
|
|
170
|
+
anything else fails at startup with `RouteConfigError`.
|
|
171
|
+
|
|
172
|
+
The short form maps to a status:
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
error_map = {SomeError: 404}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
It is exactly the full form with defaults:
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
error_map = {SomeError: rule(404)}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Both use the default `simple()` translator: `{"error": str(err)}` for 4xx, opaque message for 5xx.
|
|
185
|
+
Reach for `rule(...)` when status is not enough — for custom body, headers, side effect, or
|
|
186
|
+
richer OpenAPI.
|
|
187
|
+
|
|
188
|
+
## Resolution (MRO)
|
|
189
|
+
|
|
190
|
+
An exception is matched along its method resolution order: the most specific mapped type wins, then
|
|
191
|
+
parents in MRO order. Map `BaseError` and raise `ChildError(BaseError)` → the `BaseError` rule applies,
|
|
192
|
+
unless `ChildError` is mapped too, in which case it takes precedence.
|
|
193
|
+
|
|
194
|
+
## rule()
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
def rule(
|
|
198
|
+
status: int,
|
|
199
|
+
*,
|
|
200
|
+
translator: Translator[T] | None = None,
|
|
201
|
+
headers: Headers | None = None,
|
|
202
|
+
on_error: OnError | None = None,
|
|
203
|
+
openapi_model: type[T] | None = None,
|
|
204
|
+
openapi_description: str | None = None,
|
|
205
|
+
openapi_examples: dict[str, Any] | None = None,
|
|
206
|
+
) -> Rule: ...
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
- **`status`** — HTTP status to return (4xx or 5xx).
|
|
210
|
+
- **`translator`** — `Callable[[Exception], T]` building the response body. Its return annotation
|
|
211
|
+
becomes the OpenAPI model. Defaults to the router's translator. A translator that raises is not
|
|
212
|
+
caught and surfaces as `500` (unlike `on_error`), so handle every exception it may receive — with
|
|
213
|
+
`structured()`, guard attributes the exception may lack.
|
|
214
|
+
- **`headers`** — static `Mapping` (introspected into OpenAPI) or callable `(err) -> Mapping[str, str]`
|
|
215
|
+
(resolved per request, not introspected). The callable shapes the response, so it must return a mapping
|
|
216
|
+
and not raise. Values reach the client verbatim on every status, 5xx included, so put only
|
|
217
|
+
safe-to-expose data here. Custom `Content-Type` (e.g. `application/problem+json`) goes here too.
|
|
218
|
+
- **`on_error`** — side effect for observability: logging, metrics, alerting. Sync or async, runs
|
|
219
|
+
inline. If it raises, the failure is logged and the mapped response is still sent — a broken side
|
|
220
|
+
effect leaves the response intact.
|
|
221
|
+
- **`openapi_model`** — schema model, when the translator has no return annotation (lambda, or
|
|
222
|
+
`-> None`) or to override inference.
|
|
223
|
+
- **`openapi_description`**, **`openapi_examples`** — documentation for the response.
|
|
224
|
+
|
|
225
|
+
`on_error` is awaited before the response, so keep it light — it adds to response latency. A blocking
|
|
226
|
+
sync call (sync HTTP, disk) stalls the loop for other requests; mark it with `to_threadpool` to
|
|
227
|
+
run it off the loop:
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
from fastapi_error_map import to_threadpool
|
|
231
|
+
|
|
232
|
+
rule(503, on_error=to_threadpool(write_audit_log))
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
This offloads the loop, not the wait — the response still waits for the callback. To answer without
|
|
236
|
+
waiting, schedule the work (task, queue) and return.
|
|
237
|
+
|
|
238
|
+
One rule carrying header, callback, and documented body:
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
RateLimitedError: rule(
|
|
242
|
+
429,
|
|
243
|
+
translator=to_body,
|
|
244
|
+
headers=retry_after_header, # (err) -> {"Retry-After": ...}
|
|
245
|
+
on_error=log_rate_limit,
|
|
246
|
+
openapi_description="Per-client report quota exhausted.",
|
|
247
|
+
)
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Runnable: [`examples/extended_rule.py`](examples/extended_rule.py).
|
|
251
|
+
|
|
252
|
+
## Built-in envelopes: simple() and structured()
|
|
253
|
+
|
|
254
|
+
A translator factory turns a body format into a per-route translator. Two are built in; both keep 5xx
|
|
255
|
+
opaque so server internals never reach the client.
|
|
256
|
+
|
|
257
|
+
**`simple()`** — the default. `{"error": str(err)}` for 4xx, opaque message for 5xx. Reads only
|
|
258
|
+
`str(err)`, so it works on any exception. Rarely written out:
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
router = ErrorAwareRouter(translator_factory=simple())
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
**`structured()`** — `{code, message, details}` envelope:
|
|
265
|
+
|
|
266
|
+
```python
|
|
267
|
+
router = ErrorAwareRouter(translator_factory=structured())
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
By default it reads `err.code`, `str(err)`, and `err.details`. Missing, empty, or non-string `code`
|
|
271
|
+
falls back to the status name (`"HTTP_404_NOT_FOUND"`). Absent `message`/`details` keys are omitted,
|
|
272
|
+
never `null`.
|
|
273
|
+
|
|
274
|
+
When your exceptions carry that data under other names, point each field at its attribute —
|
|
275
|
+
explicitly, nothing is guessed:
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
structured(
|
|
279
|
+
code=lambda err: err.error_code,
|
|
280
|
+
message=lambda err: err.reason,
|
|
281
|
+
details=lambda err: err.context,
|
|
282
|
+
)
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
5xx stays opaque: `message` becomes `server_message`, never `str(err)`. Whitelist types to render in
|
|
286
|
+
full with `exposed_5xx_types`. Opacity is body-only — headers are still sent as declared.
|
|
287
|
+
|
|
288
|
+
Runnable: [`examples/structured_envelope.py`](examples/structured_envelope.py),
|
|
289
|
+
[`examples/custom_fields.py`](examples/custom_fields.py).
|
|
290
|
+
|
|
291
|
+
## Custom envelope
|
|
292
|
+
|
|
293
|
+
Need a different envelope entirely? A `TranslatorFactory` is a function returning a function —
|
|
294
|
+
`(status) -> (err) -> body`. `simple()` and `structured()` are built exactly this way.
|
|
295
|
+
|
|
296
|
+
```python
|
|
297
|
+
class ProblemDetail(TypedDict):
|
|
298
|
+
type: str
|
|
299
|
+
title: str
|
|
300
|
+
status: int
|
|
301
|
+
detail: str
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def problem_detail(status_code: int) -> Callable[[Exception], ProblemDetail]:
|
|
305
|
+
title = HTTPStatus(status_code).phrase
|
|
306
|
+
|
|
307
|
+
def translate(err: Exception) -> ProblemDetail:
|
|
308
|
+
return ProblemDetail(
|
|
309
|
+
type="about:blank", title=title, status=status_code, detail=str(err),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
return translate
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
router = ErrorAwareRouter(translator_factory=problem_detail)
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
The model is inferred from the inner translator's return annotation; pass `openapi_model=` on the rule
|
|
319
|
+
when there is none. A custom factory owns its output fully — 5xx opacity is yours to keep, so guard
|
|
320
|
+
`str(err)` at 5xx if the body might carry server detail.
|
|
321
|
+
|
|
322
|
+
This is also how you get RFC 9457 `problem+json` — the body above, plus its content type on the rule:
|
|
323
|
+
|
|
324
|
+
```python
|
|
325
|
+
ForbiddenError: rule(403, headers={"Content-Type": "application/problem+json"})
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
The runtime response carries `application/problem+json`. The OpenAPI schema documents the model under
|
|
329
|
+
`application/json` — FastAPI binds a response model to that content key, so the schema describes the
|
|
330
|
+
body's shape there regardless of the wire content type.
|
|
331
|
+
|
|
332
|
+
Runnable: [`examples/custom_factory.py`](examples/custom_factory.py).
|
|
333
|
+
|
|
334
|
+
## FastAPI interop and router-level policy
|
|
335
|
+
|
|
336
|
+
Two ways to adopt the map.
|
|
337
|
+
|
|
338
|
+
**Drop-in.** `ErrorAwareRouter` replaces `APIRouter`. Router-level arguments set policy for every route;
|
|
339
|
+
per-route `error_map` declares the specifics:
|
|
340
|
+
|
|
341
|
+
```python
|
|
342
|
+
router = ErrorAwareRouter(
|
|
343
|
+
translator_factory=structured(), # envelope for all routes
|
|
344
|
+
on_error=report, # default side effect
|
|
345
|
+
warn_on_unmapped=True, # log exceptions not in any map (default)
|
|
346
|
+
)
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Policy belongs to the router that declares the route. It is decided at that point and does not change
|
|
350
|
+
later. `include_router` keeps those routes as they are, so the map still works when you nest routers.
|
|
351
|
+
|
|
352
|
+
But policy is not inherited, unlike `dependencies` or `tags`. A child router gets nothing from its
|
|
353
|
+
parent. If the parent maps `{ServerError: 503}` and a `ServerError` is raised in a child router, it is
|
|
354
|
+
not caught: it stays unhandled. This is FastAPI's limit, not our choice.
|
|
355
|
+
`include_router` copies the child routes instead of linking them, and it does not know about our
|
|
356
|
+
`error_map`, so FastAPI gives us no way to pass policy down. For now, to use the same policy in many
|
|
357
|
+
modules, you have to set it on each router yourself: keep the shared `error_map` and settings in one
|
|
358
|
+
place, and pass them to every router that needs them.
|
|
359
|
+
|
|
360
|
+
**Without replacing your router.** Keep your own `APIRouter`; give it our `route_class` (interception
|
|
361
|
+
point) and put `@error_map` on the endpoint (the map). Both parts are required; any custom `route_class`
|
|
362
|
+
must subclass `ErrorAwareRoute`:
|
|
363
|
+
|
|
364
|
+
```python
|
|
365
|
+
router = APIRouter(route_class=ErrorAwareRoute)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@router.get("/accounts/{account_id}/")
|
|
369
|
+
@error_map({ForbiddenError: 403})
|
|
370
|
+
def get_account(account_id: int) -> Account: ...
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Runnable: [`examples/interop.py`](examples/interop.py).
|
|
374
|
+
|
|
375
|
+
## Pass-through and unmapped exceptions
|
|
376
|
+
|
|
377
|
+
`HTTPException` and `RequestValidationError` are rendered by FastAPI, never by the map — even under a
|
|
378
|
+
broad `{Exception: 500}`. On `ErrorAwareRouter`, mapping one of them has no effect and warns with
|
|
379
|
+
`ErrorMapWarning` at declaration; mapping `422` warns too, as it shadows request validation — give
|
|
380
|
+
your own validation error a different 4xx, such as `400`. The `@error_map` interop path behaves
|
|
381
|
+
identically — framework exception passes through, `422` is shadowed — but emits no warning.
|
|
382
|
+
|
|
383
|
+
Unmapped exceptions are re-raised with their original type and traceback, reaching your global
|
|
384
|
+
`@app.exception_handler(...)` unchanged. `warn_on_unmapped` (on by default) logs each one first, so a
|
|
385
|
+
missing entry surfaces in the logs; it controls only that warning, never the re-raise. Passing
|
|
386
|
+
`error_map` to a WebSocket route fails at startup; mapping wraps HTTP only.
|
|
387
|
+
|
|
388
|
+
## OpenAPI generation
|
|
389
|
+
|
|
390
|
+
From the map, the schema picks up automatically: status codes, the response model (translator return
|
|
391
|
+
annotation, or `openapi_model`), static header names, and any `openapi_description` /
|
|
392
|
+
`openapi_examples`. Several exceptions on one status become an `anyOf` union. Callable headers are
|
|
393
|
+
per-request, so they are not introspected. Any `responses=` you pass yourself wins on a status it shares
|
|
394
|
+
with the map.
|
|
395
|
+
|
|
396
|
+
The schema is read from the same declaration that handles the error (Figure 1) — one source, no drift.
|
|
397
|
+
|
|
398
|
+
## Examples
|
|
399
|
+
|
|
400
|
+
Each file is runnable with `python -m examples.<name>`:
|
|
401
|
+
|
|
402
|
+
- [`quickstart.py`](examples/quickstart.py) — one exception, one status.
|
|
403
|
+
- [`readme_quickstart.py`](examples/readme_quickstart.py) — the Quickstart above, end to end.
|
|
404
|
+
- [`structured_envelope.py`](examples/structured_envelope.py) — `structured()` with 5xx kept opaque.
|
|
405
|
+
- [`custom_fields.py`](examples/custom_fields.py) — `structured()` reading your own attribute names.
|
|
406
|
+
- [`custom_factory.py`](examples/custom_factory.py) — custom envelope (RFC 9457 `problem+json`).
|
|
407
|
+
- [`extended_rule.py`](examples/extended_rule.py) — one `rule()` with header, side effect, and docs.
|
|
408
|
+
- [`interop.py`](examples/interop.py) — adoption via `route_class` + `@error_map`.
|
|
409
|
+
|
|
410
|
+
For a larger application, see the [Clean Architecture example app](https://github.com/ivan-borovets/fastapi-clean-example).
|
|
411
|
+
|
|
412
|
+
## License
|
|
413
|
+
|
|
414
|
+
[Apache-2.0](LICENSE).
|