FAPI002 undocumented-error-response¶
What it does¶
Reports a FastAPI path operation that can produce an error status code (4xx by default) that its OpenAPI entry doesn't declare. One finding per endpoint, on its decorator, lists the codes and where each one comes from, so the claim can be checked. A code counts as produced, in rings:
| Ring | Source |
|---|---|
| 0 | raise HTTPException(404) or HTTPException(status_code=404) in the endpoint, and return JSONResponse(..., status_code=404) (any FastAPI or Starlette response class) |
| 1 | The same, in a function of the same file the endpoint calls by name, or in a dependency given as Depends(fn) or Annotated[T, Depends(fn)] |
| 2 | The same, in a first-party function or dependency imported from another module, read lazily. dependencies=[Depends(...)] on the decorator, the APIRouter(...), an include_router(...) or FastAPI(...) counts for every route under it |
| 3 | A raised first-party exception that an app's exception handler answers with a literal status code, looked up through the exception's base classes as Starlette does; and an HTTPException subclass whose __init__ fixes the code |
Rings 1 and 2 follow at most max-depth calls (2 by default). A code is an int literal, a status.HTTP_* constant from FastAPI or Starlette, an HTTPStatus member, or a first-party module-level constant holding one.
A code counts as declared when responses= holds it (an int or string key, or "4XX", "5XX" or "default") on the decorator, on the route's APIRouter(...), on an include_router(...) that leads to it, or on FastAPI(...); **NAME of a module-level dict and openapi_extra={"responses": {...}} count too. FastAPI documents 422 itself for an operation that takes parameters, so FAPI002 does too unless explicit-422 = "report".
Why is this bad¶
FastAPI builds the schema from what the code declares, and it can't see a raise (fastapi#9124: declare it in responses=). An agent adds a 404 in a helper, or a domain error an app handler turns into a 409, without touching the decorator. The generated client then has no branch for the error, and the frontend finds out in production.
Example¶
With the three layers from INW001, turn FAPI002 on:
[tool.inwards]
layers = [
{ name = "domain", modules = ["shop.domain"] },
{ name = "application", modules = ["shop.application"] },
{ name = "infrastructure", modules = ["shop.infrastructure"] },
]
[tool.inwards.rules]
extend-select = ["FAPI002"]
A service raises a domain error, and the app maps it to 409:
import shop.domain.order
class OrderOut: ...
class OutOfStock(Exception): ...
def reserve(order: OrderOut) -> None:
raise OutOfStock()
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from shop.infrastructure import orders
from shop.infrastructure.db import OutOfStock
app = FastAPI()
app.include_router(orders.router)
@app.exception_handler(OutOfStock)
async def out_of_stock(request: Request, exc: OutOfStock) -> JSONResponse:
return JSONResponse(status_code=409, content={"detail": "Out of stock"})
An endpoint loads the order through a helper that raises 404, then reserves it:
from fastapi import APIRouter, HTTPException
from shop.infrastructure.db import OrderOut, reserve
router = APIRouter(prefix="/orders", tags=["orders"])
def load_or_404(order_id: int) -> OrderOut:
raise HTTPException(status_code=404, detail="No such order")
@router.post("/{order_id}/confirm", status_code=200, summary="Confirm an order")
async def confirm_order(order_id: int) -> OrderOut:
order = load_or_404(order_id)
reserve(order)
return order
shop/infrastructure/orders.py:12:1: FAPI002 `confirm_order` can return 404 and 409, which its OpenAPI entry doesn't declare.
fix: Declare 404 and 409 in `responses=` on `@router.post("/{order_id}/confirm")`.
1. `confirm_order` can return 404 (from `load_or_404` at shop/infrastructure/orders.py:9) and 409 (from `OutOfStock` raised at shop/infrastructure/db.py:11, handled in shop/infrastructure/app.py:11), but its OpenAPI entry declares none of them.
2. Add `responses={404: {"description": "..."}, 409: {"description": "..."}}` to the decorator (with a "model" where the error has a body), or to `APIRouter(...)` if every route shares them.
3. Don't remove the `raise`, and don't catch the error only to silence this finding: clients generated from the schema need to know the error exists.
docs: https://sircypkowskyy.github.io/inwards/rules/FAPI002/
Fixed: the decorator declares both errors.
from fastapi import APIRouter, HTTPException
from shop.infrastructure.db import OrderOut, reserve
router = APIRouter(prefix="/orders", tags=["orders"])
def load_or_404(order_id: int) -> OrderOut:
raise HTTPException(status_code=404, detail="No such order")
@router.post(
"/{order_id}/confirm",
status_code=200,
summary="Confirm an order",
responses={
404: {"description": "No such order"},
409: {"description": "A product is out of stock"},
},
)
async def confirm_order(order_id: int) -> OrderOut:
order = load_or_404(order_id)
reserve(order)
return order
How to fix¶
- Check each code against its source: the finding names the file and line of every
raiseor handler. - Declare the codes in
responses=, with adescriptionand, where the error has a body, amodel. Put them on theAPIRouter(...)or theinclude_router(...)when every route under it shares them. - Don't delete the
raiseor catch the error just to make the finding go away.
Fix safety¶
No automatic fix (autofix: false). The description and the error model are part of the API.
Configuration¶
Opt-in: it reports only when extend-select or select lists FAPI002. Its options, with their defaults:
[tool.inwards.rules]
extend-select = ["FAPI002"]
[tool.inwards.rules.undocumented-error-response]
codes = "4xx" # or "4xx-5xx"
max-depth = 2 # calls followed into helpers and dependencies, 0 to 8
report-direct-raises = true # false leaves raises in the endpoint itself to Ruff FAST004
handled-counts-as-documented = false # true: codes from app exception handlers count as declared
explicit-422 = "ignore" # "report": a raised 422 must be declared too
An unknown key or a wrong value is a config error that names the key. Like every rule, it also takes modules, and like every content rule check-tests, which makes it report in test modules too (options tables). A finding can be suppressed on the decorator's line with # inwards: ignore[FAPI002] reason="...".
Ruff's FAST004 (in review) covers ring 0 with same-file responses=. When it ships, report-direct-raises = false keeps the two from reporting the same raise.
Known limitations¶
Unknown means silent: FAPI002 prefers a missed finding to a wrong one.
- A status code it can't read (
HTTPException(code)), aresponses=that isn't a literal (responses=build()), a decorator with**kwargs, or an inclusion it can't resolve (include_router(getattr(m, "router")), a factory whose returned router it can't read,include_router(r, **opts)) makes that code or endpoint unknown, and nothing is reported for it. One unresolved inclusion anywhere makes every route on a router unknown. Inclusions are read from the app and router graph FAPI003 uses (graph.ts), so a loop over a literal list of routers counts, and so do a function's parameters and a router factory's call, one hop deep (FAPI003). - Methods on injected objects (
svc.place()) aren't followed; neither are third-party code,getattrdispatch or anything the app does at runtime (routes added in loops, an overriddenapp.openapi(), middleware). - There's no flow analysis: a
raisecounts if it is in the function, even on a branch this endpoint never takes. Anexcept Xaround a call subtracts only first-party exceptions that areXor inherit from it. - Exception handlers count only on the apps that serve the route: the app it is declared on, or each app that includes its router, directly or through other routers. A handler on another app (a worker's health-check app, say) doesn't count, and neither does one on the app that mounts the route's app, since a mounted app handles its own exceptions.
- Handlers registered on a function's parameter (Polar's
add_exception_handlers(app)) aren't read, and neither is a code a handler takes from the exception (status_code=exc.status_code); both are #400. - Handlers are read from
@app.exception_handler(X),app.add_exception_handler(X, h)(also in aforloop over a list or tuple literal of exceptions) andexception_handlers=onFastAPI(...). That table can be a dict, a name bound to one in the same file, or an"exception_handlers"entry in a dict splatted intoFastAPI(**kwargs), including a dict that a caller in the same file passes to an app factory. A**kwargswhose dict sets other keys still hides the app's other keywords, and its routes stay unknown. - A raised custom exception counts for nothing on an app with a registration Inwards can't read: a loop over a dict of exceptions, or a splatted dict that comes from another file, is changed in place (
kwargs.update(...)), or belongs to a factory no caller in the file reaches. The same goes for a class that inherits from a third-party exception, whose handler Inwards may not see. A handler for a builtin or third-party class, such as anExceptioncatch-all, is read but maps no code: catch-alls often pick the code by the exception's type inside. - A generator function called by name (the body of a
StreamingResponse) isn't read, since the call doesn't run it. - Where several inclusions lead to one router, a code declared on any of them counts.
- To find what sits above a router, FAPI002 reads every FastAPI file of the project once per check. In the per-edit hook it does so only for a route with a code its own decorator and router don't declare.
- Which routers count (#358). A route counts on a name bound to FastAPI's
FastAPI(...)orAPIRouter(...), and, when the file calls a route decorator,include_routeror another router method on the name, also to a first-party router class or factory, which Inwards resolves through the project's imports: a subclass of FastAPI's class; a module-levelNAME = factory(...)whose first-party class factory says or shows that it returns a router class (-> type[APIRouter], or a subclass it defines and returns, as Polar'sget_api_router_classdoes); any module-levelAPIRouter = <call>orFastAPI = <call>; a first-party function that returns a router, one hop deep; and a name returned by a function annotated-> APIRouter(fastapi-clean-example'smake_*_router()factories). A callee merely namedAPIRouterdoesn't count: OpenStack projects name their WSGI routers that. A factory's arguments aren't the router's keywords, so a factory-built router's prefix, tags andresponses=are unknown, and its routes count as shown. A file that spells none of the pre-filter's words (fastapi,APIRouter,include_router...) isn't read. - Route classes (#392). FastAPI builds each route with its router's
route_class, and builds it again with the joined tags at eachinclude_router(routing.py,add_api_routeandinclude_router). The route class comes fromroute_class=or from a first-party router class that sets it (kwargs["route_class"] = route_classin a class factory, as Polar'sget_api_router_class). When one__init__among its first-party classes setsself.include_in_schemafrom the tags, Inwards runs that code for each route: anif/elif/elsechain over<tag> in self.tags(or a local bound to it) andself.include_in_schema, assigningTrueorFalse. Tags are strings or first-party enum members holding one (Polar'sAPITag.private, aStrEnum), from the decorator, the router and everyinclude_routerabove it. A route is skipped when the class leaves it out, and also when the answer is unknown: a value other than a literal (Polar'ssettings.is_development()for private routes), a condition or statement it can't read, a tag it can't read, or a route class that writesself.tags. A route class that doesn't touch the schema changes nothing. - Measured on the corpus (#182, chapter 6): 18 findings before the
openapi_urlrule, 17 after; 8 looked at, all true (each claimed code is raised by the endpoint or a dependency). The removed one was on Polar's back office, an app withopenapi_url=None. Since #358 the model sees Polar's 526 path operations outside tests and fastapi-clean-example's 15, Since #391 follows the function parameter Polar includes its API router through and the factory calls both projects include routers through, every inclusion on both is known. Polar then has 1 finding, true (an OAuth2authorizeroute that raises 401 and declares nothing); most of its other routes raisePolarErrorsubclasses whose handler FAPI002 can't read yet (#400), and its webhook and integration routes areinclude_in_schema=False. fastapi-clean-example still has none: its routers come from a factory around a third-partyErrorAwareRouter, whose keywords, and so its declared responses, are unknown.
References¶
- ADR-037: Framework rule families, opt-in, with their own prefix
- FastAPI: Additional Responses in OpenAPI, Handling Errors
- fastapi/fastapi#9124, astral-sh/ruff#25120 (
FAST004)