# Why did my FastAPI route start returning 422?

> Chimera CH-05 (FastAPI): 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.

```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:

```text
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:

```text
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:

```text
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/

## Rules it tests

- [AR-08: How do I write dispatch that type checkers can follow?](https://beta.llmgraph.in/agent-readable/ar-08.md)
- [AR-33: How do I keep FastAPI routes visible to coding agents?](https://beta.llmgraph.in/agent-readable/ar-33.md)

Last verified 2026-10-30
