fastapi-error-map 0.9.9__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.
Files changed (75) hide show
  1. {fastapi_error_map-0.9.9 → fastapi_error_map-1.0.0}/.github/workflows/ci.yaml +4 -2
  2. {fastapi_error_map-0.9.9 → fastapi_error_map-1.0.0}/.github/workflows/publish.yaml +1 -1
  3. {fastapi_error_map-0.9.9 → fastapi_error_map-1.0.0}/.github/workflows/test-compatibility.yaml +0 -1
  4. {fastapi_error_map-0.9.9 → fastapi_error_map-1.0.0}/.gitignore +3 -0
  5. fastapi_error_map-1.0.0/.python-version +1 -0
  6. {fastapi_error_map-0.9.9 → fastapi_error_map-1.0.0}/Makefile +4 -1
  7. fastapi_error_map-1.0.0/PKG-INFO +414 -0
  8. fastapi_error_map-1.0.0/README.md +388 -0
  9. fastapi_error_map-1.0.0/docs/example-openapi.png +0 -0
  10. fastapi_error_map-1.0.0/examples/custom_factory.py +80 -0
  11. fastapi_error_map-1.0.0/examples/custom_fields.py +57 -0
  12. fastapi_error_map-1.0.0/examples/extended_rule.py +76 -0
  13. fastapi_error_map-1.0.0/examples/interop.py +43 -0
  14. fastapi_error_map-1.0.0/examples/quickstart.py +45 -0
  15. fastapi_error_map-1.0.0/examples/readme_quickstart.py +55 -0
  16. fastapi_error_map-1.0.0/examples/structured_envelope.py +58 -0
  17. {fastapi_error_map-0.9.9 → fastapi_error_map-1.0.0}/noxfile.py +3 -3
  18. fastapi_error_map-1.0.0/pyproject.toml +190 -0
  19. fastapi_error_map-1.0.0/src/fastapi_error_map/__init__.py +44 -0
  20. fastapi_error_map-1.0.0/src/fastapi_error_map/concurrency.py +54 -0
  21. fastapi_error_map-1.0.0/src/fastapi_error_map/framework.py +19 -0
  22. fastapi_error_map-1.0.0/src/fastapi_error_map/handler.py +93 -0
  23. fastapi_error_map-1.0.0/src/fastapi_error_map/http_status.py +17 -0
  24. fastapi_error_map-1.0.0/src/fastapi_error_map/openapi.py +68 -0
  25. fastapi_error_map-1.0.0/src/fastapi_error_map/route_config.py +68 -0
  26. fastapi_error_map-1.0.0/src/fastapi_error_map/routing.py +226 -0
  27. fastapi_error_map-1.0.0/src/fastapi_error_map/rules.py +230 -0
  28. fastapi_error_map-1.0.0/src/fastapi_error_map/translator_factories/__init__.py +19 -0
  29. fastapi_error_map-1.0.0/src/fastapi_error_map/translator_factories/common.py +3 -0
  30. fastapi_error_map-1.0.0/src/fastapi_error_map/translator_factories/simple.py +44 -0
  31. fastapi_error_map-1.0.0/src/fastapi_error_map/translator_factories/structured.py +119 -0
  32. fastapi_error_map-1.0.0/src/fastapi_error_map/types_.py +25 -0
  33. fastapi_error_map-1.0.0/tests/factories.py +59 -0
  34. fastapi_error_map-1.0.0/tests/test_build_errors.py +73 -0
  35. fastapi_error_map-1.0.0/tests/test_concurrency.py +189 -0
  36. fastapi_error_map-1.0.0/tests/test_examples.py +149 -0
  37. fastapi_error_map-1.0.0/tests/test_handling.py +392 -0
  38. fastapi_error_map-1.0.0/tests/test_headers.py +127 -0
  39. fastapi_error_map-1.0.0/tests/test_openapi.py +432 -0
  40. fastapi_error_map-1.0.0/tests/test_route_config.py +55 -0
  41. fastapi_error_map-1.0.0/tests/test_routing.py +336 -0
  42. fastapi_error_map-1.0.0/tests/test_translators.py +363 -0
  43. fastapi_error_map-1.0.0/tests/typing_cases/schema_runtime.py +30 -0
  44. {fastapi_error_map-0.9.9 → fastapi_error_map-1.0.0}/uv.lock +317 -456
  45. fastapi_error_map-0.9.9/PKG-INFO +0 -351
  46. fastapi_error_map-0.9.9/README.md +0 -324
  47. fastapi_error_map-0.9.9/docs/example-openapi.png +0 -0
  48. fastapi_error_map-0.9.9/examples/errors.py +0 -31
  49. fastapi_error_map-0.9.9/examples/main.py +0 -53
  50. fastapi_error_map-0.9.9/fastapi_error_map/__init__.py +0 -10
  51. fastapi_error_map-0.9.9/fastapi_error_map/error_handling.py +0 -85
  52. fastapi_error_map-0.9.9/fastapi_error_map/openapi.py +0 -39
  53. fastapi_error_map-0.9.9/fastapi_error_map/routing.py +0 -2574
  54. fastapi_error_map-0.9.9/fastapi_error_map/rules.py +0 -98
  55. fastapi_error_map-0.9.9/fastapi_error_map/translator_policy.py +0 -29
  56. fastapi_error_map-0.9.9/fastapi_error_map/translators.py +0 -75
  57. fastapi_error_map-0.9.9/pyproject.toml +0 -193
  58. fastapi_error_map-0.9.9/tests/integration/test_example.py +0 -40
  59. fastapi_error_map-0.9.9/tests/integration/test_exclude_none.py +0 -85
  60. fastapi_error_map-0.9.9/tests/integration/test_routing.py +0 -54
  61. fastapi_error_map-0.9.9/tests/integration/test_threadpool.py +0 -76
  62. fastapi_error_map-0.9.9/tests/unit/__init__.py +0 -0
  63. fastapi_error_map-0.9.9/tests/unit/error_stubs.py +0 -10
  64. fastapi_error_map-0.9.9/tests/unit/test_error_handling.py +0 -232
  65. fastapi_error_map-0.9.9/tests/unit/test_openapi.py +0 -85
  66. fastapi_error_map-0.9.9/tests/unit/test_rules.py +0 -174
  67. fastapi_error_map-0.9.9/tests/unit/test_translators.py +0 -43
  68. fastapi_error_map-0.9.9/tests/unit/translator_stubs.py +0 -40
  69. {fastapi_error_map-0.9.9 → fastapi_error_map-1.0.0}/.pre-commit-config.yaml +0 -0
  70. {fastapi_error_map-0.9.9 → fastapi_error_map-1.0.0}/LICENSE +0 -0
  71. {fastapi_error_map-0.9.9 → fastapi_error_map-1.0.0}/examples/__init__.py +0 -0
  72. {fastapi_error_map-0.9.9 → fastapi_error_map-1.0.0/src}/fastapi_error_map/py.typed +0 -0
  73. {fastapi_error_map-0.9.9 → fastapi_error_map-1.0.0}/tests/__init__.py +0 -0
  74. {fastapi_error_map-0.9.9/tests/integration → fastapi_error_map-1.0.0/tests}/conftest.py +0 -0
  75. {fastapi_error_map-0.9.9/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.9"
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: uv run pytest --cov=fastapi_error_map --cov-branch --cov-report=xml
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
@@ -27,7 +27,7 @@ jobs:
27
27
  uses: astral-sh/setup-uv@v7
28
28
 
29
29
  - name: Build
30
- run: uv build
30
+ run: uv build --python 3.13
31
31
 
32
32
  - name: Upload artifact
33
33
  uses: actions/upload-artifact@v6
@@ -15,7 +15,6 @@ jobs:
15
15
  os:
16
16
  - ubuntu-latest
17
17
  python-version:
18
- - "3.9"
19
18
  - "3.10"
20
19
  - "3.11"
21
20
  - "3.12"
@@ -161,3 +161,6 @@ cython_debug/
161
161
 
162
162
  # IgnoreToDo
163
163
  todo/
164
+
165
+ # Claude Code / local agent config
166
+ .claude/
@@ -0,0 +1 @@
1
+ 3.10
@@ -7,10 +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
15
+ coverage run -m pytest -v
16
+ coverage report --show-missing
14
17
 
15
18
  check: lint test
16
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
+ [![PyPI version](https://badge.fury.io/py/fastapi-error-map.svg)](https://badge.fury.io/py/fastapi-error-map)
30
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/fastapi-error-map)
31
+ [![codecov](https://codecov.io/gh/ivan-borovets/fastapi-error-map/branch/master/graph/badge.svg?token=ABTVQLI0RL)](https://codecov.io/gh/ivan-borovets/fastapi-error-map)
32
+ ![GitHub License](https://img.shields.io/github/license/ivan-borovets/fastapi-error-map)
33
+ [![CI](https://img.shields.io/github/actions/workflow/status/ivan-borovets/fastapi-error-map/ci.yaml)](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).