Agent Rules / Chimeras
Why did my FastAPI route start returning 422?
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.
# 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# 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"atroutes.py:10is resolved bygetattratroutes.py:15when the module is imported. A search for callers ofbuild_refunds_reportfinds its definition atreports.py:25and the string, and no call. - The route is registered by a loop. No decorator sits over any report function. The FastAPI reference describes
getas the way to add a path operation using an HTTP GET operation; here it is called as a plain function atroutes.py:16, so the URL/reports/refundsappears 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.pycalls builders directly andtest_routes.pyrequests 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:
# 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.
--- 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)--- 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 overrefunds_report, and the router hasprefix="/reports". The bigger applications tutorial describesAPIRouteras a "mini FastAPI" class andinclude_routeras adding all the router's routes to the app. A search for/reports/refundsor forrefunds_reportfinds the route. - The query contract is stated in the route.
reason_codeis declared withAnnotated[str, Query(min_length=1)]and no default, which the query validation tutorial gives as the way to make aQueryparameter 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.pyrequests/reports/refundswith and withoutreason_code, so the route's contract has a test. - Annotated is the documented preference. The query validation tutorial says to use
Annotatedfor 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
getattris 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 inmain.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 anythingAsked 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 spotsThe 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,
missingerror body): https://fastapi.tiangolo.com/tutorial/query-params/ - Query parameter validations (
Annotated[str, Query(min_length=3)], required without a default,Annotatedpreferred): https://fastapi.tiangolo.com/tutorial/query-params-str-validations/ - Numeric validations (
ge,gt,lewithQuery): https://fastapi.tiangolo.com/tutorial/path-params-numeric-validations/ - Bigger applications (
APIRouteras a "mini FastAPI" class,prefix,include_router): https://fastapi.tiangolo.com/tutorial/bigger-applications/ APIRouterreference (getadds 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