You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: CLAUDE.md
+22-4Lines changed: 22 additions & 4 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -6,14 +6,14 @@ Public PyPI package: typed HTTP errors for FastAPI — exact `Literal` error cod
6
6
7
7
## Status
8
8
9
-
Draft. Only the `core`layer is implemented. Linters/type checker are configured (see Tooling); tests are not set up yet. Licensed under MIT (`LICENSE` + PEP 639 metadata in `pyproject.toml`).
9
+
Draft. The `core`and `decorator` layers are implemented. Linters/type checker are configured (see Conventions); tests are not set up yet (verification via ad-hoc smoke scripts outside the repo). Licensed under MIT (`LICENSE` + PEP 639 metadata in `pyproject.toml`).
10
10
11
11
## Architecture — three independent layers
12
12
13
13
Each layer is usable without the next one:
14
14
15
15
1.**`core`** (done) — `BaseError`, the `BaseErrorMeta` metaclass, `ErrorResponse`, `error_models()`, `handle_base_error`.
16
-
2.**`decorator`** (planned) — `with_errors(router)` + `Support[...]` in the return annotation (Annotated syntax). A router wrapper with a single interception point in `add_api_route`; subclassing `APIRouter`was rejected.
16
+
2.**`decorator`** (done) — `with_errors(router)` + `Raises[...]` in the return annotation (Annotated syntax). An instance patch of `add_api_route` as the single interception point; subclassing `APIRouter`AND a wrapper object were both rejected (see decorator-layer decisions).
17
17
3.**`analysis`** (planned) — AST walk over `raise` statements: a CI checker (declared vs actually raised) and `auto=True` (auto-populating `responses`). Study fastapi-docx before implementing.
-**No `install()`-style helpers** — the user wires everything explicitly, the FastAPI way (`app.add_exception_handler(BaseError, handle_base_error)`), same as `app.add_middleware(...)`. Starlette resolves handlers by walking `__mro__`, so the `BaseError` handler overrides the default `HTTPException` one without touching plain `HTTPException`.
40
42
-`handle_base_error` is typed `(Request, Exception)` to match Starlette's `ExceptionHandler` contract: parameters are contravariant, so a narrower `BaseError` parameter would force a suppression onto every user's registration line (verified with ty; dropping the generic parameter does not help). Misregistration is caught at runtime instead — a non-`BaseError` argument raises a `TypeError` naming the offending type.
41
43
-`BaseErrorMeta` is public so users can resolve metaclass conflicts (mixing `BaseError` with `ABC` etc.) via a combined metaclass, but it is deliberately NOT re-exported from any `__init__` — import from `fastapi_typed_errors.core.base`. Documented in README.
42
-
- Rule for the future decorator layer: an explicit user `responses={}` wins over `Support` errors with the same status.
44
+
- Rule for the future decorator layer: an explicit user `responses={}` wins over `Raises` errors with the same status.
45
+
46
+
## Key decorator-layer decisions
47
+
48
+
-**Instance patch, not a wrapper object** (user decision 2026-07-22, supersedes the concept doc): `with_errors[R: APIRouter](router: R, /) -> R` replaces `router.add_api_route` with a `functools.wraps` decorator on the *instance* and returns the *same* router. Reason: FastAPI 0.139's lazy `include_router` keeps the included object and `APIRouter.matches` does an **identity check** (`included_router.original_router is self`) — any duck-typed wrapper passes OpenAPI generation but silently 404s at runtime. Identity preservation makes `include_router`, websockets, app-level decorators and imperative registration work natively on every FastAPI version.
49
+
- Single funnel: all 8 verb decorators + `api_route` + all `app.*` methods delegate into `router.add_api_route` (verified in 0.139 sources), so one patch covers everything. For an application: `with_errors(app.router)`.
50
+
- The patch is idempotent (flag attribute `_fastapi_typed_errors_wrapped` on the replacement).
51
+
- FastAPI's decorators pass `response_model` explicitly as a `DefaultPlaceholder` sentinel — "user set response_model" means the kwarg is present AND not a `DefaultPlaceholder`. Never inject `response_model=None` otherwise: it would disable return-annotation inference.
52
+
-`Raises` is an inert `Annotated` metadata instance (`__class_getitem__` returns an instance; no `__get_pydantic_core_schema__`); pydantic ignores it, the 200 schema stays clean. Validation (BaseError subclass, declared code + status, non-empty) happens eagerly at `Raises[...]` evaluation, i.e. import time.
53
+
- Normalization: `-> Annotated[Response-subclass | None, Raises[...]]` → `response_model=None` (FastAPI's `lenient_issubclass` cannot see through `Annotated`; without this the route dies with `FastAPIError` / grows a spurious `null` schema). Bare stream returns (`AsyncIterator` + Raises) are unsupported — documented limitation.
54
+
- Merge semantics: derived entries lose to explicit per-route `responses` wholesale per status; router-level `responses` are merged by FastAPI itself and lose to per-route ones.
55
+
- Endpoint unwrapping for annotation reading only: `functools.partial` chain → `inspect.unwrap` → callable-instance `type(obj).__call__`; the original endpoint object is always what gets registered.
56
+
-`_find_raises` is recursive: unwraps PEP 695 `TypeAliasType`, descends into `Annotated` bases and union arms — a declared marker must never be dropped silently (adversarial review caught all three as silent-drop bugs). `_annotated_base` applies the same unwrapping for the Response/None normalization.
57
+
- Unresolvable type hints (NameError/TypeError from `get_type_hints`): if the raw return annotation does not mention `Raises` → silent passthrough (stock FastAPI tolerates the `TYPE_CHECKING` pattern and never resolves return hints under an explicit `response_model`); if it does → fail fast with a clear `TypeError`. Never resolve stricter than stock for marker-free endpoints.
58
+
- Follow-up idea (not implemented): PEP 692 `Unpack[TypedDict]` typing for registration kwargs.
43
59
44
60
## Conventions
45
61
46
62
- This file and all code artifacts (docstrings, comments) are in English; communication with the user is in Russian.
47
63
- Commits: Conventional Commits with English descriptions (`feat(core): ...`, `chore: ...`, `docs: ...`).
48
64
- Member ordering, both at module level and inside classes: public first, then protected (`_name`), then private. Deviate only when definition-time dependencies force it — e.g. in `core/base.py``_model_title` must precede `ErrorResponse` (referenced in its class body), and `ErrorResponse` -> `BaseErrorMeta` -> `BaseError` is a hard dependency chain (annotations evaluate eagerly on Python < 3.14).
- Prefer PEP 695 generics (`[**P, R]`, `[R]`) over `Callable[..., Any]` in decorator/pass-through helpers; when P.kwargs must be mutated, localize the lie in a single `cast(dict[str, Any], kwargs)` alias instead of suppressions. Module-level constants are annotated `Final`.
67
+
- Relative imports inside the package are welcome (`from ..core.base import ...`) — TID252 (`relative-imports`) is in the ignore list.
50
68
-`Returns:` must state the return type: `type: Description` (e.g. `str | None: The declared code...`).
51
69
- Type checker: **ty**, max strictness (`[tool.ty.rules] all = "error"`). Suppressions use ty-style comments (`# ty: ignore[rule]`); ty does not recognize mypy rule codes inside `# type: ignore[...]`.
52
70
- Linter/formatter: **ruff** with `select = ["ALL"]` + `preview = true`, ignoring only the `TC` and `CPY` modules and `missing-trailing-comma` (COM812, formatter conflict); pydocstyle convention = google. Formatter: double quotes, `line-length = 120`. Import order: ruff isort defaults (stdlib `import` then `from` imports, third-party, local) — exactly the preferred style, no extra config needed.
Copy file name to clipboardExpand all lines: README.md
+49-16Lines changed: 49 additions & 16 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -2,22 +2,26 @@
2
2
3
3
Typed error responses for FastAPI: `Literal` error codes in OpenAPI, discriminated `oneOf` unions, and a single source of truth — the error class itself.
4
4
5
-
> **Status: draft.**Layer 1 (`core`) only; the `with_errors` decorator and AST analysis layers are coming.
5
+
> **Status: draft.**Layers 1 (`core`) and 2 (`decorator`) are implemented; the AST analysis layer (CI checker + `auto`) is coming.
6
6
7
7
## Requirements
8
8
9
9
- Python **3.12+** (PEP 695 generics)
10
-
- FastAPI ≥ 0.115, Pydantic ≥ 2.7
10
+
- FastAPI ≥ 0.115, Pydantic ≥ 2.9
11
11
12
12
## Quick start
13
13
14
+
Declare errors once, right in the return annotation — `with_errors` fills `responses={}` for you:
15
+
14
16
```python
15
17
from enum import StrEnum
16
18
from http import HTTPStatus
17
-
from typing import Literal
19
+
from typing import Annotated, Literal
20
+
21
+
from fastapi import APIRouter, FastAPI
22
+
from pydantic import BaseModel
18
23
19
-
from fastapi import FastAPI
20
-
from fastapi_typed_errors import BaseError, error_models, handle_base_error
24
+
from fastapi_typed_errors import BaseError, Raises, handle_base_error, with_errors
21
25
22
26
23
27
classErrorCode(StrEnum):
@@ -34,34 +38,63 @@ class ForbiddenError(BaseError[Literal[ErrorCode.FORBIDDEN]]):
and OpenAPI shows the **exact**`Literal` code per status — several errors on one status become a discriminated `oneOf` union via `error_models(A, B, ...)`.
67
+
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.
68
+
69
+
`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)`.
70
+
71
+
## Core layer only
72
+
73
+
The decorator layer is optional sugar — `responses={}` can always be filled by hand with `error_models()`:
74
+
75
+
```python
76
+
@app.get(
77
+
"/items/{item_id}",
78
+
responses={
79
+
404: {"model": error_models(NotFoundError)},
80
+
403: {"model": error_models(ForbiddenError)},
81
+
},
82
+
)
83
+
defget_item(item_id: int) -> Item: ...
84
+
```
61
85
62
86
## Notes
63
87
64
88
- Error codes are any `StrEnum` members you bring, or plain strings: `BaseError[Literal["NOT_FOUND"]]`.
89
+
- Wrap **before** registering: routes added to the router before `with_errors(router)` are not retrofitted.
90
+
- An explicit `responses={<status>: ...}` on the route wins wholesale over `Raises`-derived entries for the same status. Use `int` status keys.
91
+
- Shared error tuples work in both spellings: `Raises[*TOKEN_ERRORS]` and `Raises(*TOKEN_ERRORS)`.
92
+
- 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.
93
+
- 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`.
94
+
-`-> Annotated[Response subclass, Raises[...]]` and `-> Annotated[None, Raises[...]]` are normalized to `response_model=None`, restoring stock FastAPI semantics for raw-response and empty routes.
95
+
- Bare stream returns (`-> Annotated[AsyncIterator[X], Raises[...]]`, the SSE/JSONL feature) are not supported — annotate a `Response` subclass instead.
96
+
-`Raises` metadata on a router that was **not** passed through `with_errors` is inert — nothing is injected and nothing fails (the upcoming analysis layer will catch such drift).
97
+
- 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.
65
98
- Customize the response body by overriding `response_base` with your own generic subclass of `ErrorResponse`.
66
99
-`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).
67
100
- 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.
0 commit comments