|
1 | 1 | # fastapi-typed-errors |
2 | 2 |
|
3 | | -Typed error responses for FastAPI: `Literal` error codes in OpenAPI, discriminated `oneOf` unions, and a single source of truth — the error class itself. |
| 3 | +**English** · [Русский](README.ru.md) |
4 | 4 |
|
5 | | -> **Status: draft.** All three layers are implemented — `core`, the `with_errors` decorator (with `auto`-fill), and the layer-3 CI checker. |
| 5 | +  [](https://feodor-ra.github.io/fastapi-typed-errors/) |
6 | 6 |
|
7 | | -## Requirements |
| 7 | +**Typed HTTP errors for FastAPI** — exact `Literal` codes in OpenAPI, discriminated `oneOf` unions on the `code` field, and a single source of truth: the error class itself. |
8 | 8 |
|
9 | | -- Python **3.12+** (PEP 695 generics) |
10 | | -- FastAPI ≥ 0.115 (the lazy-routing gap `0.137`–`0.138` is excluded), Pydantic ≥ 2.9 |
11 | | -- The CI checker CLI needs the `cli` extra: `pip install 'fastapi-typed-errors[cli]'` |
12 | | - |
13 | | -## Quick start |
14 | | - |
15 | | -Declare errors once, right in the return annotation — `with_errors` fills `responses={}` for you: |
| 9 | +In plain FastAPI, `raise HTTPException(404, "...")` buries the error code in a string — clients can't switch on it, OpenAPI has no code type or body schema, and you hand-maintain `responses={}` on every route. This package makes an error a **class**: its HTTP status, machine-readable code and response model are declared once and derived automatically, so your error contract is typed, self-documenting, and verifiable in CI. |
16 | 10 |
|
17 | 11 | ```python |
18 | | -from enum import StrEnum |
19 | | -from http import HTTPStatus |
20 | | -from typing import Annotated, Literal |
21 | | - |
22 | | -from fastapi import APIRouter, FastAPI |
23 | | -from pydantic import BaseModel |
24 | | - |
25 | | -from fastapi_typed_errors import BaseError, Raises, handle_base_error, with_errors |
26 | | - |
27 | | - |
28 | | -class ErrorCode(StrEnum): |
29 | | - NOT_FOUND = "NOT_FOUND" |
30 | | - FORBIDDEN = "FORBIDDEN" |
31 | | - |
32 | | - |
33 | 12 | class NotFoundError(BaseError[Literal[ErrorCode.NOT_FOUND]]): |
34 | 13 | http_status = HTTPStatus.NOT_FOUND |
35 | | - description = "Requested entity does not exist" |
36 | 14 |
|
37 | 15 |
|
38 | | -class ForbiddenError(BaseError[Literal[ErrorCode.FORBIDDEN]]): |
39 | | - http_status = HTTPStatus.FORBIDDEN |
40 | | - |
| 16 | +@router.get("/items/{item_id}") |
| 17 | +def get_item(item_id: int) -> Annotated[Item, Raises[NotFoundError]]: |
| 18 | + if item_id == 0: |
| 19 | + raise NotFoundError("No item") |
| 20 | + return Item(item_id=item_id) |
| 21 | +``` |
41 | 22 |
|
42 | | -class Item(BaseModel): |
43 | | - item_id: int |
| 23 | +The response body is always `{"code": "NOT_FOUND", "detail": "No item"}`, and OpenAPI gets a `404` with the **exact** `Literal["NOT_FOUND"]` code and body model — no manual `responses`. |
44 | 24 |
|
| 25 | +## Install |
45 | 26 |
|
46 | | -app = FastAPI() |
47 | | -app.add_exception_handler(BaseError, handle_base_error) |
| 27 | +```bash |
| 28 | +pip install fastapi-typed-errors # core + decorator |
| 29 | +pip install "fastapi-typed-errors[cli]" # + the CI-checker CLI |
| 30 | +``` |
48 | 31 |
|
49 | | -router = with_errors(APIRouter()) |
| 32 | +Requires Python **3.12+**, FastAPI **≥ 0.115**, Pydantic **≥ 2.9**. |
50 | 33 |
|
| 34 | +## How to use it |
51 | 35 |
|
52 | | -@router.get("/items/{item_id}") |
53 | | -def get_item(item_id: int) -> Annotated[Item, Raises[NotFoundError, ForbiddenError]]: |
54 | | - if item_id == 0: |
55 | | - raise NotFoundError(f"No item {item_id}") |
56 | | - return Item(item_id=item_id) |
| 36 | +**1. Define errors and register the one handler.** |
57 | 37 |
|
| 38 | +```python |
| 39 | +from fastapi import FastAPI |
| 40 | +from fastapi_typed_errors import BaseError, handle_base_error |
58 | 41 |
|
59 | | -app.include_router(router) |
| 42 | +app = FastAPI() |
| 43 | +app.add_exception_handler(BaseError, handle_base_error) |
60 | 44 | ``` |
61 | 45 |
|
62 | | -The error response body is always: |
| 46 | +**2. Declare errors — pick your level of magic:** |
63 | 47 |
|
64 | | -```json |
65 | | -{"code": "NOT_FOUND", "detail": "No item 0"} |
66 | | -``` |
| 48 | +```python |
| 49 | +# a) by hand (core only) — write responses yourself |
| 50 | +@app.get("/x", responses={404: {"model": error_models(NotFoundError)}}) |
| 51 | +def a() -> Item: ... |
67 | 52 |
|
68 | | -OpenAPI gets a `404` and a `403` entry with the **exact** `Literal` code each; several errors sharing one status become a discriminated `oneOf` union automatically. The success (`200`) schema stays clean — the `Raises` marker is invisible to pydantic. |
69 | 53 |
|
70 | | -`with_errors(router)` returns the **same** `APIRouter` instance with its `add_api_route` patched on the instance, so object identity is preserved: `include_router`, websockets, imperative `add_api_route(...)` calls and app-level decorators all work natively. For an application, wrap its router: `with_errors(app.router)`. |
| 54 | +# b) declared — the marker fills responses for you |
| 55 | +router = with_errors(APIRouter()) |
71 | 56 |
|
72 | | -### Auto-fill |
73 | 57 |
|
74 | | -Pass `with_errors(router, auto=True)` to drop the `Raises[...]` markers entirely: at registration each endpoint and its whole dependency tree are statically walked (the same walk the [CI checker](#ci-checker) uses), and the discovered errors fill `responses` automatically — merged with any markers you *do* write, and an explicit `responses={}` still wins per status. |
| 58 | +@router.get("/y") |
| 59 | +def b() -> Annotated[Item, Raises[NotFoundError, ForbiddenError]]: ... |
75 | 60 |
|
76 | | -```python |
| 61 | + |
| 62 | +# c) automatic — no markers at all; errors are found statically |
77 | 63 | router = with_errors(APIRouter(), auto=True) |
78 | 64 |
|
79 | 65 |
|
80 | | -@router.get("/items/{item_id}") |
81 | | -def get_item(item_id: int, user: Annotated[User, Depends(current_user)]) -> Item: |
82 | | - if item_id == 0: |
83 | | - raise NotFoundError(f"No item {item_id}") # auto -> 404 |
84 | | - return Item(item_id=item_id) # + whatever current_user can raise |
| 66 | +@router.get("/z") |
| 67 | +def c(user: Annotated[User, Depends(current_user)]) -> Item: |
| 68 | + raise NotFoundError("...") # auto -> 404, plus whatever current_user raises |
85 | 69 | ``` |
86 | 70 |
|
87 | | -## Core layer only |
88 | | - |
89 | | -The decorator layer is optional sugar — `responses={}` can always be filled by hand with `error_models()`: |
| 71 | +**3. Verify the contract in CI.** `check_raises` compares what each route *declares* against what it can actually *raise* — in the endpoint and its whole dependency tree: |
90 | 72 |
|
91 | 73 | ```python |
92 | | -@app.get( |
93 | | - "/items/{item_id}", |
94 | | - responses={ |
95 | | - 404: {"model": error_models(NotFoundError)}, |
96 | | - 403: {"model": error_models(ForbiddenError)}, |
97 | | - }, |
98 | | -) |
99 | | -def get_item(item_id: int) -> Item: ... |
| 74 | +def test_error_contracts() -> None: |
| 75 | + assert check_raises(app).ok |
100 | 76 | ``` |
101 | 77 |
|
102 | | -## CI checker |
| 78 | +Or as a command: `fastapi-typed-errors check app.main:app` (exit `0`/`1`/`2`). |
103 | 79 |
|
104 | | -`check_raises` statically compares what each route **declares** via `Raises[...]` against what it can actually **raise** — in the endpoint, its helpers and its whole dependency tree. It closes the gap that pure annotations leave open: a raise you forgot to declare, or a declaration you no longer raise. |
| 80 | +## Why it's cool |
105 | 81 |
|
106 | | -```python |
107 | | -from fastapi_typed_errors import check_raises |
| 82 | +- **Exact types in OpenAPI** — a precise `Literal` code per status; several errors on one status become a discriminated `oneOf` union, so Swagger UI shows a variant picker by code. |
| 83 | +- **Single source of truth** — status, code and model declared once; the metaclass derives the rest. |
| 84 | +- **Zero-boilerplate `responses`** — via the `Raises` marker or fully automatic `auto=True`. |
| 85 | +- **Static contract checking** — `check_raises` catches a raise you forgot to declare (or a dead declaration) before it ships. |
| 86 | +- **Non-invasive** — `with_errors` patches the router in place and preserves object identity, so `include_router`, websockets and app-level decorators keep working natively. |
| 87 | +- **Rigorous** — fully typed (`py.typed`), 100% branch-covered, checked with `ruff` + `ty` at max strictness. |
108 | 88 |
|
| 89 | +## Documentation |
109 | 90 |
|
110 | | -def test_error_contracts() -> None: |
111 | | - report = check_raises(app) # a FastAPI app or an APIRouter |
112 | | - assert report.ok, report.routes |
113 | | -``` |
114 | | - |
115 | | -`report.routes` lists each `RouteDiscrepancy` with two independent buckets: `undeclared` (raised in code, missing from `Raises` — always a failure) and `overdeclared` (declared but never found raised). Pass `allow_overdeclared=True` to ignore the second bucket for code whose raises the static walker cannot see. |
| 91 | +📖 **[Full documentation](https://feodor-ra.github.io/fastapi-typed-errors/)** — guide, customization (custom envelopes, `ABC`, bare-string codes), limitations, and an auto-generated API reference. Available in English and Russian. |
116 | 92 |
|
117 | | -The same check as a CLI (needs the `cli` extra), pointed at a `module:attribute` app path: |
118 | | - |
119 | | -```console |
120 | | -$ fastapi-typed-errors check app.main:app |
121 | | -``` |
| 93 | +## License |
122 | 94 |
|
123 | | -Exit codes: `0` all declarations match, `1` discrepancies (rendered as a table), `2` a usage/loading error. Flags: `--allow-overdeclared`, `--max-depth`. |
124 | | - |
125 | | -The walker follows the `get_or_404(error=NotFoundError)` factory pattern (error classes passed as call arguments), closures, cross-module helpers and `functools.partial`; it stays within one process and never executes your code. Out of scope (documented false-negatives, so `overdeclared` stays a failure by default): errors raised through local-variable indirection, `self.method()` chains, dynamic dispatch, and bare `AsyncIterator` stream endpoints. |
126 | | - |
127 | | -## Notes |
128 | | - |
129 | | -- Error codes are any `StrEnum` members you bring, or plain strings: `BaseError[Literal["NOT_FOUND"]]`. |
130 | | -- Wrap **before** registering: routes added to the router before `with_errors(router)` are not retrofitted. |
131 | | -- An explicit `responses={<status>: ...}` on the route wins wholesale over `Raises`-derived entries for the same status. Use `int` status keys. |
132 | | -- Shared error tuples work in both spellings: `Raises[*TOKEN_ERRORS]` and `Raises(*TOKEN_ERRORS)`. |
133 | | -- Markers are found through PEP 695 `type` aliases, nested `Annotated` bases and union arms (`Annotated[Item, Raises[...]] | None`) — a declared `Raises` is never dropped silently. |
134 | | -- Unresolvable return annotations without `Raises` (the `if TYPE_CHECKING:` import pattern) pass through untouched, exactly like stock FastAPI; with `Raises` mentioned they fail fast with a clear `TypeError`. |
135 | | -- `-> Annotated[Response subclass, Raises[...]]` and `-> Annotated[None, Raises[...]]` are normalized to `response_model=None`, restoring stock FastAPI semantics for raw-response and empty routes. |
136 | | -- Bare stream returns (`-> Annotated[AsyncIterator[X], Raises[...]]`, the SSE/JSONL feature) are not supported — annotate a `Response` subclass instead. |
137 | | -- `Raises` metadata on a router that was **not** passed through `with_errors` is inert — nothing is injected and nothing fails; the [CI checker](#ci-checker) reads annotations directly, so it still catches such drift. |
138 | | -- Status descriptions come from each error's `description`; several errors on one status get their descriptions joined with `;`, and the HTTP status phrase is the fallback. |
139 | | -- Customize the response body by overriding `response_base` with your own generic subclass of `ErrorResponse` — extra fields are added alongside `code`/`detail` (e.g. `status: Literal["error"] = "error"`). The shape must stay **flat**: nested envelopes like `{"status": ..., "data": {"code": ...}}` are not supported, because pydantic discriminated unions require the `code` discriminator at the top level of the model. |
140 | | -- `BaseError` subclasses `fastapi.HTTPException`; mixing it with `ABC` and other custom-metaclass bases raises a metaclass conflict. The metaclass is public for exactly this case — build a combined one: `class Meta(BaseErrorMeta, ABCMeta): ...` (`from fastapi_typed_errors.core.base import BaseErrorMeta`; deliberately not re-exported from the package root). |
141 | | -- The handler is registered explicitly — the same way you call `app.add_middleware(...)`; the package does not touch your app behind your back. `handle_base_error` is typed `(Request, Exception)` to match Starlette's handler contract, so the registration line stays clean under every type checker; registering it for a non-`BaseError` exception type fails fast with a `TypeError` at runtime. |
| 95 | +[MIT](LICENSE). |
0 commit comments