For AI agentsA Markdown index of these docs is at /llms.txt. Every page is also Markdown: add .md to its address.

Agent Rules / Chimeras

Why did my FastAPI route start returning 422?

Chimera CH-05 · FastAPI

Rules it tests: AR-08, AR-33

A loop reads a dict of URL keys and function names, looks each function up with getattr, and registers it as a route. FastAPI then builds the URL's query parameters from that function's signature, so a signature edit in the reports module changes the public URL without touching the routes file. Write each route as a decorated function on an APIRouter, declare its query parameters with Annotated, and include the routers by name.

The code below is from a small example project we wrote for this page. The break is shown by file and line, and we ran the project's own tests and server to confirm it.

1. The setup

FastAPI makes this pattern short, and the parts are good ones. A path operation function is an ordinary function, FastAPI reads its parameters as the request's inputs, and a router can be built anywhere. A reports API has three builders in reports.py and wants one GET route for each.

python# before/reportsapi/routes.py REPORT_HANDLERS = { "sales": "build_sales_report", "refunds": "build_refunds_report", # line 10 "inventory": "build_inventory_report", } for key, name in REPORT_HANDLERS.items(): # line 14 handler = getattr(reports, name) # line 15 router.get(f"/reports/{key}")(handler) # line 16
python# before/reportsapi/main.py app.include_router(router)

The tests call the builders directly (tests/test_reports.py) and send one request through TestClient (tests/test_routes.py, GET /reports/sales).

2. Where the call path hides

  • The handler is a string. "build_refunds_report" at routes.py:10 is resolved by getattr at routes.py:15 when the module is imported. A search for callers of build_refunds_report finds its definition at reports.py:25 and the string, and no call.
  • The route is registered by a loop. No decorator sits over any report function. The FastAPI reference describes get as the way to add a path operation using an HTTP GET operation; here it is called as a plain function at routes.py:16, so the URL /reports/refunds appears in no file.
  • The function signature is the URL contract. The FastAPI query parameters tutorial says function parameters that are not part of the path are interpreted as query parameters, and that a parameter with no default value is required. The builder was written as a Python function, and it is also an HTTP interface.
  • The tests stop before the seam. test_reports.py calls builders directly and test_routes.py requests one URL. Neither exercises the registration of the other two.

3. The edit that breaks it, and which call is left wrong

Refunds need a filter, so build_refunds_report gains a keyword-only argument with no default:

python# before/reportsapi/reports.py (edit applied) def build_refunds_report(year: int = 2026, *, reason_code: str) -> dict: # line 25 (was: def build_refunds_report(year: int = 2026) -> dict:)

The edit is finished in the file it touches. The direct test was updated with it: tests/test_reports.py:9 passes reason_code="damaged" and is correct. The call left wrong is the registration at routes.py:16. It still hands the function to router.get(...) unchanged, and FastAPI reads reason_code as a required query parameter. A client that calls GET /reports/refunds as before no longer gets a report. Per the query parameters tutorial, a missing required parameter produces an error body with type: "missing", loc: ["query", "reason_code"] and the message Field required, a validation error. Run against FastAPI 0.162.0, GET /reports/refunds returns status 422 with exactly that body, while GET /reports/sales still returns 200.

Nothing fails at import and nothing fails in the builder tests. GET /reports/sales at test_routes.py:10 still passes, so a smoke test on the API looks healthy. The case that would catch it is written at test_routes.py:19 to :22 as an xfail with the reason. With pytest the before/ suite reports 4 passed and 1 xfailed; the after/ suite reports 6 passed, including the test that a missing reason_code returns 422.

A second edit has the opposite shape. If build_refunds_report is renamed to build_refund_report, a language server renames the definition and the direct test, and does not touch the string at routes.py:10. Importing reportsapi.routes then raises AttributeError at line 15, so this one is loud. It is loud far from the cause: the traceback ends in the loop, and the mismatch is between a string in a dict and a name in another file.

4. The fix

One router per report, a decorated function per route, parameters declared in the route's signature with Annotated, and routers included by name. The route function calls the builder with a plain, visible call.

diff--- before/reportsapi/routes.py+++ after/reportsapi/routers/refunds.py-REPORT_HANDLERS = {- "sales": "build_sales_report",- "refunds": "build_refunds_report",- "inventory": "build_inventory_report",-}--for key, name in REPORT_HANDLERS.items():- handler = getattr(reports, name)- router.get(f"/reports/{key}")(handler)+router = APIRouter(prefix="/reports", tags=["reports"])+++@router.get("/refunds")+def refunds_report(+ reason_code: Annotated[str, Query(min_length=1)],+ year: Annotated[int, Query(ge=2000)] = 2026,+) -> dict:+ return build_refunds_report(year, reason_code=reason_code)
diff--- before/reportsapi/main.py+++ after/reportsapi/main.py-from reportsapi.routes import router+from reportsapi.routers import inventory, refunds, sales app = FastAPI(title="Reports API")-app.include_router(router)+app.include_router(sales.router)+app.include_router(refunds.router)+app.include_router(inventory.router)

sales.py and inventory.py in after/reportsapi/routers/ follow the same shape. reports.py is unchanged apart from the explanatory comment.

5. Why the fix works

  • The URL is written where the function is. @router.get("/refunds") sits over refunds_report, and the router has prefix="/reports". The bigger applications tutorial describes APIRouter as a "mini FastAPI" class and include_router as adding all the router's routes to the app. A search for /reports/refunds or for refunds_report finds the route.
  • The query contract is stated in the route. reason_code is declared with Annotated[str, Query(min_length=1)] and no default, which the query validation tutorial gives as the way to make a Query parameter required. The HTTP interface is no longer a side effect of the builder's signature.
  • The builder is called by name. build_refunds_report(year, reason_code=reason_code) is an ordinary call. A caller search finds it, and a rename of the builder renames this line with it. If the builder gains another required argument, this call is missing it. A type checker that reads annotations reports a missing argument at that line; that was not run for this page.
  • Behaviour is tested at the URL. after/tests/test_routes.py requests /reports/refunds with and without reason_code, so the route's contract has a test.
  • Annotated is the documented preference. The query validation tutorial says to use Annotated for new code and whenever possible, and gives the default value staying the real default value as one reason.

6. Rules that apply

  • AR-08: dispatch over a closed set should be written so a checker can follow it. A dict of function names handed to getattr is a table the checker cannot read; a decorated function per route is a direct name.
  • AR-33: routes are decorated functions on an APIRouter, included by name in main.py. Do not build routes in loops from strings.

7. What LLMGRAPH shows

Asked who calls build_refunds_report in before/, LLMGRAPH proves the one direct caller and says the list may not be the end of it:

llmgraph · who_calls build_refunds_report (reportsapi/reports.py:25): 1 certain ✓ certain tests/test_reports.py:9 test_refunds_report_filters_by_reason ⚠ plus 1 dynamic call nearby that could reach anything

Asked where its view is blind, it names the file that holds the loop:

llmgraph · what_is_unknown (whole repo): 1 blind spot ⚠ dynamic call 1: reportsapi/routes.py (1)

That is the registration left wrong in section 3. An agent that changes the signature is told, before it finishes, that a call it can't pin down sits in routes.py, which is the file to open.

On after/, the same two questions:

llmgraph · who_calls build_refunds_report (reportsapi/reports.py:23): 2 certain ✓ certain reportsapi/routers/refunds.py:15 refunds_report ✓ certain tests/test_reports.py:9 test_refunds_report_filters_by_reason llmgraph · what_is_unknown (whole repo): 0 blind spots

The route is now a proven caller, and nothing in the project is left open.

8. References

FastAPI (fetched 2026-10-30):

  • Query parameters (non-path parameters are query parameters, required when no default, missing error body): https://fastapi.tiangolo.com/tutorial/query-params/
  • Query parameter validations (Annotated[str, Query(min_length=3)], required without a default, Annotated preferred): https://fastapi.tiangolo.com/tutorial/query-params-str-validations/
  • Numeric validations (ge, gt, le with Query): https://fastapi.tiangolo.com/tutorial/path-params-numeric-validations/
  • Bigger applications (APIRouter as a "mini FastAPI" class, prefix, include_router): https://fastapi.tiangolo.com/tutorial/bigger-applications/
  • APIRouter reference (get adds a path operation using an HTTP GET operation): https://fastapi.tiangolo.com/reference/apirouter/
  • Testing (TestClient, client.get, status_code): https://fastapi.tiangolo.com/tutorial/testing/

Last verified

For agentsthis page as Markdown/agent-readable/ch-05.mdllms.txt