Repository navigation
tech-debt(api): fastapi>=0.139 defers include_router() — the repo knows this in four places and guards it in none #15093
Description
Verdict
Zero sites are genuinely broken today. A full sweep of every .routes read in the repo (134 hits across 60 files) found no site that reads a router which actually includes children and expects the flattened result — except the two that already handle it. The blast radius of the FastAPI 0.141.1 deferred-inclusion change is, right now, nil.
That is luck, not design. The knowledge that the change exists is already written down in four separate places, each with a private workaround, and there is no shared helper and no guard. The next site to read a parent router's .routes will get a silently wrong answer, and nothing in CI will say so.
This issue records the class, the sweep that bounds it today, and asks for the guard that keeps the bound.
The behaviour
Under fastapi>=0.139 (CI resolves 0.141.1 / starlette 1.6.0), parent.include_router(child) no longer flattens the child's routes into parent.routes. It appends a single deferred _IncludedRouter entry with no .path and no .methods; the child's real routes materialise only when an app mounts the router, and are otherwise reachable only via included.original_router.routes.
Consequences at a call site:
- an unguarded
route.pathwalk raisesAttributeError: '_IncludedRouter' object has no attribute 'path' - the same walk guarded with
hasattr/getattrsilently finds nothing — the strictly worse outcome len(router.routes)counts one entry perinclude_router()call, not per endpoint
How it was proven
CI job 98088603289 (python-suite shard 12/12, run 32939657631, conclusion failure) on PR #15085 dumped the assembled router's top level as:
[APIWebSocketRoute /ws/{session_id}, APIWebSocketRoute /ws/ssh/{host_id}, _IncludedRouter path=null]
— three entries, one of them a deferred wrapper with a null path. The same code run locally produced 26 routes. Fixed on that PR by mounting a FastAPI() app and enumerating app.routes instead of walking the router.
The local/CI split is itself tracked as #15091: this development box resolves fastapi 0.135.2 / starlette 1.0.0, below the declared floor of fastapi>=0.141.1 (autobot-backend/requirements.txt, autobot-slm-backend/requirements.txt) and below the CI pin fastapi==0.141.1 (requirements-ci/framework.txt). A local pass carries no information about this behaviour. Everything below is reasoned from the documented semantics plus the CI evidence; it was not reproduced locally, and it cannot be until #15091 lands.
Sweep and classification
Method: git grep -n '\.routes' -- '*.py' → 134 hits / 60 files. Import lines (from .routes import router) discarded. For every remaining site the router object was resolved back to its defining module and that module checked for include_router() calls against that same router.
The decisive fact: the set of routers that include children is disjoint from the set of routers whose .routes anyone reads — with exactly two exceptions, both already handled.
Routers that call include_router() on themselves:
autobot-backend/api/{knowledge,terminal,agent_terminal,transcriber,chat,voice_bundle_admin,analytics}.py, autobot-backend/api/codebase_analytics/router.py, autobot-backend/llc/api/__init__.py (37 children), autobot-backend/middleware/builtin/transcriber_extension.py (combined).
Of those, only llc/api/__init__.py and codebase_analytics/router.py have their .routes read anywhere — and both readers already traverse .original_router.
Already handled (2)
| Site | How |
|---|---|
autobot-backend/llc/tests/test_roles_routes_registered.py:64-73, 90-92 |
Walks .original_router.routes, with an else [included] fallback so it is correct on both eager and deferred inclusion. Documents the behaviour verbatim at :53-56. |
autobot-backend/api/codebase_analytics/endpoints/impact_endpoint_test.py:159-164 |
Walks .original_router.routes. Version-locked — see caveat below. |
Handled by sidestepping the router entirely (3)
| Site | How |
|---|---|
autobot-backend/api/self_capabilities.py:65-72 (_openapi_paths) |
Reads the table through get_openapi(routes=app.routes), FastAPI's own supported view. Correct on every version. |
autobot-backend/api/self_capabilities_integration_test.py:170-179 |
Same, with the behaviour documented at :171-172 (cites #13551). |
scripts/audit_api_wiring.py:129-137, 155-165, 330-345 |
Runtime walk kept only as a supplementary net, unioned with a version-independent static source scan which is the source of truth. Documented at :131-134 and :337-343 (cites #12381, #12432). |
Degraded — passes, but verifies or reports less than it claims (3)
| Site | Effect |
|---|---|
autobot-backend/api/self_capabilities.py:178, 189 |
_schema_hash(len(app.routes)) is the cache-invalidation key. Under 0.141.1 that count changes only when an include_router() call is added, not when an endpoint inside an already-included router changes — so the cache does not bust. The docstrings at :167 and :218 claim it is "invalidated automatically when routes change" / "resets on route changes"; that claim is false on the declared FastAPI. Bounded by the 5-minute TTL, so staleness is capped, but the documented contract is not met. |
autobot-backend/api/presence_ws_router_test.py:65 |
assert len(app.routes) > 0 after app.include_router(router) — satisfied by the wrapper alone. Vacuous on either FastAPI version; asserts nothing about the routes being mounted. |
autobot-infrastructure/shared/scripts/test_real_startup.py:82 |
Prints App has {len(main.app.routes)} routes — a diagnostic that will under-report by orders of magnitude. Cosmetic. |
Latent — correct today only because the router happens to have no children (3)
| Site | If the router ever gains a child |
|---|---|
autobot-backend/services/mcp_bridge_workers/worker_entrypoint.py:98 |
Scans bridge.router.routes for a POST route ending /mcp/<tool>. No api.* module defines mcp_call_tool, so this fallback is the sole dispatch path for every MCP bridge worker. None of filesystem_mcp/git_mcp/database_mcp/browser_mcp/http_client_mcp/autobot_mcp_router uses include_router, so it works. If one ever did, the tool would become undiscoverable → RuntimeError: tool X not found. Fail-loud, not wrong-endpoint. |
autobot-backend/api/batch_jobs.py:736 (_find_route_handler) |
Walks app.routes matching .path; hasattr-guarded, so it would silently return None → "Endpoint {endpoint} not found" for every sub-request. Already tracked in #13558, together with the separate defect that makes it unreachable anyway. |
autobot-backend/api/developer.py:47 (APIRegistry.register_router) |
hasattr(route, "path") and hasattr(route, "methods") → would silently skip every deferred child. Moot: register_router( is never called anywhere in the repo (git grep 'register_router(' returns only the definition), so api_registry.endpoints is permanently empty and the four developer endpoints that serve it (:106, :166, :195, :224) report zero endpoints regardless of FastAPI version. Unfinished wiring, not a version bug — noted here so it is not swept as debris. |
Not affected (all remaining sites)
Every other reader resolves to a router built purely from @router.get/post/put/delete/websocket decorators, which register eagerly on every FastAPI version, or reads an app rather than a router. Named in full:
autobot-backend/api/agents_self_improvement_test.py:29,58 · autobot-backend/api/marketplace_sources_routes_test.py:18 (+ the plugin_manager router) · autobot-backend/api/presence_ws_router_test.py:31,34,40 · autobot-backend/api/settings_telemetry_test.py:58 · autobot-backend/api/telegram_bot_test.py:43 · autobot-backend/api/whatsapp_test.py:37 · autobot-backend/backend_startup_e2e_test.py:33,53 · autobot-backend/tests/api/test_web_research_settings_guard.py:40 · autobot-backend/tests/test_diagnostics_router_registration.py:51,57 · autobot-slm-backend/tests/api/test_prometheus_scrape_is_unauthenticated_14339.py:66 · autobot-backend/llc/tests/test_agent_hires_auth.py:99 · autobot-backend/llc/tests/test_contact_directory.py:300 · autobot-backend/llc/tests/test_findings_api.py:395 · autobot-backend/tests/api/test_onboarding_auth.py:153,178 · autobot-backend/tests/integration/test_mobile_device_me.py:109 · autobot-backend/tests/initialization/test_analytics_cost_router.py:28,42,51,59,83,94,97 · autobot-backend/tests/initialization/test_analytics_export_router.py:28,42,51,59,76 · autobot-backend/tests/initialization/test_knowledge_grounding_router.py:28,36,45,53,68 · autobot-backend/tests/initialization/test_health_celery_dead_letter.py:27 · autobot-backend/utils/operation_timeout_integration_test.py:76 · autobot-backend/api/self_capabilities_test.py:51,264 (synthetic fixture) · autobot-infrastructure/shared/scripts/utilities/validate_chat_knowledge.py:155 · autobot-infrastructure/shared/scripts/utilities/verify_backend_config.py:198,200 · autobot-backend/api/codebase_analytics/api_endpoint_scanner.py (static AST/regex scan, no runtime introspection).
Two of these deserve a note because they look dangerous and are not:
autobot-backend/api/long_running_operations.py:153, 220, 273, 325—await manager.router.routes[0].endpoint(create_request), in production code (api.long_running_operationsis registered atinitialization/router_registry/feature_routers.py:418).manager.routerisOperationIntegrationManager.router(autobot-backend/utils/operation_timeout_integration.py:112); that file contains zeroinclude_routercalls, and_setup_routes()(:318) calls_setup_crud_routes()first, whose first decorator is@self.router.post("/create")→create_operation(:327-328). Soroutes[0].endpointis stillcreate_operationunder 0.141.1. Neither anAttributeErrornor a wrong endpoint. It remains a fragile idiom — reordering_setup_crud_routessilently redirects four production endpoints — but that is a separate robustness concern, not this class.autobot-backend/tests/initialization/test_analytics_cost_router.py/test_analytics_export_router.py— these readanalytics_cost.router/analytics_export.router, which are the children included byautobot-backend/api/analytics.py:402,404. Reading a child is unaffected; only readinganalytics.routerwould break.
Counts
| Category | Count |
|---|---|
| Genuinely broken | 0 |
| Already handled | 2 |
| Handled by sidestepping | 3 |
| Degraded (passes, verifies/reports less) | 3 |
| Latent (safe only by accident) | 3 |
| Not affected | all remaining |
The real finding: the knowledge exists and did not spread
Four places in the repo already know about this. Each solved it privately. None produced a shared helper, and none left anything that would catch a fifth site:
| Where | Workaround | Cites |
|---|---|---|
autobot-backend/llc/tests/test_roles_routes_registered.py:53-56 |
.original_router walk with a fallback |
#14221 |
autobot-backend/api/codebase_analytics/endpoints/impact_endpoint_test.py:159-164 |
.original_router walk without a fallback |
#13506 |
autobot-backend/api/self_capabilities_integration_test.py:170-179 |
get_openapi() |
#13551 |
scripts/audit_api_wiring.py:131-134, 337-343 |
static scan, runtime walk demoted to a net | #12381, #12432 |
git grep -n 'original_router' -- '*.py' returns two files. There is no helper in autobot_shared/. Meanwhile autobot_shared/api_routing/router_prefixes.py already exposes INCLUDE_ROUTER_NAME_RE (:74) and _INCLUDE_ROUTER_TARGET_RE (:66) — precisely the static machinery a guard needs — and repo_tests/router_prefix_convergence_test.py is the precedent for a repo-wide source-parsing guard built on it.
Caveat: one handler is version-locked the other way
autobot-backend/api/codebase_analytics/endpoints/impact_endpoint_test.py:164 does:
for sub in getattr(included, "original_router", None).routesNo None fallback. On an eager-inclusion FastAPI (<0.139, i.e. what the dev box in #15091 actually resolves), router.routes holds real APIRoute objects with no original_router, so getattr(...) returns None and the line raises AttributeError: 'NoneType' object has no attribute 'routes'. Correct in CI, crashes locally. test_roles_routes_registered.py:66 gets this right with subroutes = original.routes if original is not None else [included]; that is the form a shared helper should take. Sharpens the case for #15091.
Acceptance criteria
-
A shared traversal helper exists in
autobot_shared/that, given anAPIRouter, yields its effective routes correctly on both eager and deferred inclusion (thetest_roles_routes_registered.py:66form, recursing through nested inclusion). The four existing private workarounds are migrated to it;git grep 'original_router'afterwards points at the helper and its tests, not at scattered call sites. -
A guard fails when a new site reads
<router>.routesexpecting flattened children. A static repo-wide check (host:repo_tests/, alongsiderouter_prefix_convergence_test.py; reuseautobot_shared/api_routing/router_prefixes.pyforinclude_routerdetection rather than a new regex — core rule 2). It must:- scan every tracked
*.pyfor reads of<name>.routeswhere<name>resolves to anAPIRouter(not aFastAPIapp), - resolve
<name>to its defining module and determine whether that router receivesinclude_router()calls, - fail on any such site that does not go through the shared helper,
get_openapi(), or a mounted app, - carry an explicit allowlist for the sidestepping sites (
self_capabilities.py,audit_api_wiring.py), each entry annotated with why it is exempt.
- scan every tracked
-
The guard is proven to bite — contrast mutation. A test adds a fixture module to a temp tree containing a parent router that does
parent.include_router(child)plus a site doingfor r in parent.routes: r.path, runs the guard's scanner over that tree, and asserts it is reported. Then the same fixture with theinclude_routerline removed (decorator-only router, everything else byte-identical) must not be reported. Both halves required: the first proves detection, the second proves the guard is not simply flagging every.routesread. -
Non-vacuity — an empty sweep fails loudly. The guard asserts its scanner found a non-zero number of candidate sites (routers-with-children whose
.routesare read, allowlisted ones included) before evaluating any of them. A path-resolution or import bug that makes the scan match nothing must turn the check red, not green. Precedent:scripts/audit_api_wiring_test.py:139-143(test_dump_openapi_websocket_union_survives_empty_runtime_walk) pairs a positive assertion with_runtime_websocket_paths_returns_empty(fake_app)for exactly this reason, andautobot-backend/llc/tests/test_roles_routes_registered.py:101-105records that the first draft of that file "passed" while verifying nothing. This property caught a real defect on fix(terminal): stop the router-level admin Depends 500ing both terminal WS handshakes #15085 — it is not decoration. -
The degraded sites are resolved, each either fixed or its claim corrected:
self_capabilities.py:178,189— cache key derived from something that actually changes with the route table (e.g. theget_openapi()path count already computed at :190), or the docstrings at :167 and :218 amended to stop claiming route-change invalidation.presence_ws_router_test.py:65— assert a real mounted path instead oflen(app.routes) > 0.test_real_startup.py:82— count through a version-correct view or drop the line.
-
impact_endpoint_test.py:164gets theNonefallback (via the helper from AC 1), so it stops being correct only on>=0.139. -
No fixes are in scope for chore(devenv): local verification runs fastapi 0.135.2 against a declared floor of >=0.141.1 — a local pass carries no information about CI #15091. This issue must not be closed by making the dev box match CI; the guard has to be version-independent and pass on both.
Not in scope
autobot-backend/api/batch_jobs.py— tracked in bug(api): POST /api/batch-jobs/load is dead — imports a nonexistent module, and its route lookup is blind on fastapi>=0.139 #13558.autobot-backend/api/developer.pyregister_routerdead wiring — noted above, needs its own issue if not folded in.- The
routes[0]positional-index fragility inlong_running_operations.py— correct today, separate robustness concern.
Related
- chore(devenv): local verification runs fastapi 0.135.2 against a declared floor of >=0.141.1 — a local pass carries no information about CI #15091 — this box runs
fastapi 0.135.2against a declared floor of>=0.141.1; local verification cannot observe any of this. Blocks reproducing the behaviour outside CI. - bug(terminal): both terminal WebSocket routes 500 on every handshake — a router-level Depends cannot resolve in a WS scope #14998 — the terminal WS handshake bug whose fix (fix(terminal): stop the router-level admin Depends 500ing both terminal WS handshakes #15085) surfaced the CI evidence cited above.
- fix(terminal): stop the router-level admin Depends 500ing both terminal WS handshakes #15085 — where the
_IncludedRouterdump and the 3-vs-26 route-count split were captured (job98088603289). - bug(api): POST /api/batch-jobs/load is dead — imports a nonexistent module, and its route lookup is blind on fastapi>=0.139 #13558 — the one production call site with this premise, already filed.
- test: 13 unrelated singleton failures remain in the Python suite (#13162 tail) #13551 — where
self_capabilities_integration_test.pyfirst hitAttributeError: '_IncludedRouter' object has no attribute 'path'. - audit_api_wiring: resolve SLM-backend (:8000) getBackendUrl calls so they aren't false-unwired #12381, audit_api_wiring: WS-route enumeration misses sub-router parametrized WebSocket routes (advanced-control/ws/*, overseer/ws/*) #12432 — where
audit_api_wiring.pyworked around the same change with a static scan.
Activity
test(api): guard the include_router deferral the repo knew about four…
fix(types): narrow the mount-graph AST passes for the strict autobot_…
fix(sdk): stop sending query parameters the routes do not accept (#15119
fix(sdk): stop sending query parameters the routes do not accept (#15119
fix(types): narrow the mount-graph AST passes for the strict autobot_…
github-actions commentedon Aug 26, 2026
PR #15113 (merged to
Dev_new_gui) references this issue with a close keyword.If this issue is fully resolved, close it manually. If work remains, no action is needed.
test(api): guard the include_router deferral the repo knew about four…
fix(types): narrow the mount-graph AST passes for the strict autobot_…
test(api): guard the include_router deferral the repo knew about four…
github-actions commentedon Aug 26, 2026
PR #15112 (merged to
Dev_new_gui) references this issue with a close keyword.If this issue is fully resolved, close it manually. If work remains, no action is needed.
mrveiss commentedon Aug 26, 2026
Closing. PR #15112 is merged. All seven criteria verified against the merged base; evidence kind stated per criterion.
Dev_new_guiautobot_shared/api_routing/router_routes.py—effective_routes/effective_route_count/route_method_paths, recursive and cycle-guarded, correct on eager and deferred inclusion. The AST mount graph was lifted alongside it intoautobot_shared/api_routing/mount_graph.pyso both guards share one resolver (router_mount_parity_test.py536 → 202 lines).<router>.routesreadrepo_tests/router_routes_traversal_test.py:278test_no_site_reads_routes_on_a_router_with_children, built onrouter_prefixes.py'sinclude_routergrammar rather than a new regex.EXEMPT_READSis empty —self_capabilities.pyandaudit_api_wiring.pysidestep the router entirely rather than needing an exemption, which is a better outcome than the annotated allowlist the criterion asked for.:296test_exemptions_are_named_and_still_realmakes a stale exemption an error: "a stale exemption is a hole nothing reports".:363test_the_same_tree_without_the_include_is_not_reportedis the negative half;:415test_the_fixture_source_is_what_the_scanner_thinks_it_ispins the fixture itself so the pair cannot silently drift. Live mutation on the final head: reintroducing{p for r in router.routes if (p := getattr(r, "path", None))}→1 failed, 11 passednamingimpact_endpoint_test:164; reverted →12 passed.:226requires enough.routesreads to resolve ("name resolution is broken and the guard below judges nothing"), and:234requires ≥8 routers-with-children and namesllc.api:routerexplicitly — a package__init__with 38include_routercalls, so if it drops out "the guard still passes while having stopped looking at the largest parent in the repo".self_capabilities.py:59now derives its cache key fromeffective_route_count(app)rather thanlen(app.routes), so the docstring's route-change-invalidation claim is true instead of amended away.presence_ws_router_test.pyandtest_real_startup.pyfixed; 64 affected backend tests green.impact_endpoint_test.py:164gets theNonefallbackeffective_routes(router), with the comment recording that it previously read.original_router.routeswith noNonefallback and "was correct only on >=0.139". It failed on this box before the change withAttributeError: 'NoneType' object has no attribute 'routes'— direct evidence of the divergence from the eager side.One correction to the issue's framing, established during the work. "Neither the include-time
prefix=nordependencies=is recoverable" is right, but a second prefix term was being conflated with it and is recoverable — the including router's own.prefix. Eager inclusion folds it into child paths at include time; deferred leaves it on the parent. That is exactly whytest_roles_routes_registered.pyprependsllc_router.prefixand is correct in CI while the identical line yields/llc/llc/…on an eager box. The helper adds that term only when descending into a wrapper, so it applies exactly once on both shapes.And the criterion's own wording did not survive contact. AC1 asks that
git grep 'original_router'afterwards point only at the helper and its tests. On the merged base it is three of four copies migrated, with two real uses remaining inside #15113's deliberate_walk— which the guard cannot flag, because_walkreadsgetattr(container, "routes", [])wherecontaineris a parameter: the attribute-chain-rooted-in-a-local-variable blind spot the guard documents in its own module docstring. That is a real limit of a static guard, not a clean bill, it is stated as such in the PR body, and retiring those two is #15126's job — whose acceptance criteria require deleting #15113's suffix-match workaround once this helper is available. A workaround must not outlive its reason.Declared blind spots, in the guard's docstring rather than discovered later: attribute chains rooted in a local variable (24 of 62 reads unresolved, share-capped), runtime-computed mount targets, whether a traversal's result is then used correctly, include-time
prefix=/dependencies=, and cross-tree reads — which namespacing makes a false negative rather than a false positive.The
code-qualityfailure this PR hit is worth recording. Lifting the resolver out of a test file put it under the strictautobot_shared/mypy sweep for the first time —pyproject.tomlexcludes.*_test\.py$— and produced 23 errors against code that had been running inside a required gate for weeks. All one root cause introduced by the lift (bucketing nodes intoDict[type, List[ast.AST]], which erases the element type), fixed with a typed_Bucketsdataclass filled byisinstance, zero# type: ignore. Verified not a silent behaviour change: differential run overautobot-backendgave identical sets, 21 vs 21; graph unchanged at 341 routers / 425 edges / 0 unresolved / 2 dynamic.Not proved: the deferral itself was never observed first-hand on this box (fastapi 0.135.2, #15091) — it is established from CI dumps plus documented semantics.
api/terminal.pywas held at exactly its grandfathered 1299 lines throughout.Filed from this work and still open: #15121 (three absolute-path filters, two live), #15134 (closed — the mypy pin turned out not to be load-bearing; the variable is the installed dependency set), #15126 (the include-prefix gap this helper unblocks).
test: read the settings and user-management routers through effective…
fix(tests): read routes per-router, not off the mounted app (#16375)
security(auth): admin-gate the open routers, settings, teams and user…
fix(tests): read routes per-router, not off the mounted app (#16375)
test(rate-limiting): walk api.chat with effective_routes, not .routes (…
fix(rate-limiting): enforce the per-user limit on chat sends, and rec…