Skip to content

tech-debt(api): fastapi>=0.139 defers include_router() — the repo knows this in four places and guards it in none #15093

Closed

Description

@mrveiss

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.path walk raises AttributeError: '_IncludedRouter' object has no attribute 'path'
  • the same walk guarded with hasattr/getattr silently finds nothing — the strictly worse outcome
  • len(router.routes) counts one entry per include_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_operations is registered at initialization/router_registry/feature_routers.py:418). manager.router is OperationIntegrationManager.router (autobot-backend/utils/operation_timeout_integration.py:112); that file contains zero include_router calls, and _setup_routes() (:318) calls _setup_crud_routes() first, whose first decorator is @self.router.post("/create") → create_operation (:327-328). So routes[0].endpoint is still create_operation under 0.141.1. Neither an AttributeError nor a wrong endpoint. It remains a fragile idiom — reordering _setup_crud_routes silently 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 read analytics_cost.router / analytics_export.router, which are the children included by autobot-backend/api/analytics.py:402,404. Reading a child is unaffected; only reading analytics.router would 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).routes

No 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

  1. A shared traversal helper exists in autobot_shared/ that, given an APIRouter, yields its effective routes correctly on both eager and deferred inclusion (the test_roles_routes_registered.py:66 form, 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.

  2. A guard fails when a new site reads <router>.routes expecting flattened children. A static repo-wide check (host: repo_tests/, alongside router_prefix_convergence_test.py; reuse autobot_shared/api_routing/router_prefixes.py for include_router detection rather than a new regex — core rule 2). It must:

    • scan every tracked *.py for reads of <name>.routes where <name> resolves to an APIRouter (not a FastAPI app),
    • resolve <name> to its defining module and determine whether that router receives include_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.
  3. 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 doing for r in parent.routes: r.path, runs the guard's scanner over that tree, and asserts it is reported. Then the same fixture with the include_router line 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 .routes read.

  4. Non-vacuity — an empty sweep fails loudly. The guard asserts its scanner found a non-zero number of candidate sites (routers-with-children whose .routes are 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, and autobot-backend/llc/tests/test_roles_routes_registered.py:101-105 records 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.

  5. 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. the get_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 of len(app.routes) > 0.
    • test_real_startup.py:82 — count through a version-correct view or drop the line.
  6. impact_endpoint_test.py:164 gets the None fallback (via the helper from AC 1), so it stops being correct only on >=0.139.

  7. 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

Related

Activity

  1. github-actions commented on Aug 26, 2026

    @github-actions
    Contributor

    PR #15113 (merged to Dev_new_gui) references this issue with a close keyword.

    test(terminal): fail the terminal route sweep when it enumerates nothing (#15087)

    If this issue is fully resolved, close it manually. If work remains, no action is needed.

  2. github-actions commented on Aug 26, 2026

    @github-actions
    Contributor

    PR #15112 (merged to Dev_new_gui) references this issue with a close keyword.

    test(api): guard the include_router deferral the repo knew about four times (#15093)

    If this issue is fully resolved, close it manually. If work remains, no action is needed.

  3. mrveiss commented on Aug 26, 2026

    @mrveiss
    OwnerAuthor

    Closing. PR #15112 is merged. All seven criteria verified against the merged base; evidence kind stated per criterion.

    AC Kind Evidence on merged Dev_new_gui
    1 — shared traversal helper, four private copies migrated static + route-level autobot_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 into autobot_shared/api_routing/mount_graph.py so both guards share one resolver (router_mount_parity_test.py 536 → 202 lines).
    2 — a guard fails on a new <router>.routes read static repo_tests/router_routes_traversal_test.py:278 test_no_site_reads_routes_on_a_router_with_children, built on router_prefixes.py's include_router grammar rather than a new regex. EXEMPT_READS is empty — self_capabilities.py and audit_api_wiring.py sidestep the router entirely rather than needing an exemption, which is a better outcome than the annotated allowlist the criterion asked for. :296 test_exemptions_are_named_and_still_real makes a stale exemption an error: "a stale exemption is a hole nothing reports".
    3 — contrast mutation, both halves static :363 test_the_same_tree_without_the_include_is_not_reported is the negative half; :415 test_the_fixture_source_is_what_the_scanner_thinks_it_is pins 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 passed naming impact_endpoint_test:164; reverted → 12 passed.
    4 — non-vacuity, an empty sweep fails loudly unit Two independent floors: :226 requires enough .routes reads to resolve ("name resolution is broken and the guard below judges nothing"), and :234 requires ≥8 routers-with-children and names llc.api:router explicitly — a package __init__ with 38 include_router calls, so if it drops out "the guard still passes while having stopped looking at the largest parent in the repo".
    5 — degraded sites resolved static + route-level self_capabilities.py:59 now derives its cache key from effective_route_count(app) rather than len(app.routes), so the docstring's route-change-invalidation claim is true instead of amended away. presence_ws_router_test.py and test_real_startup.py fixed; 64 affected backend tests green.
    6 — impact_endpoint_test.py:164 gets the None fallback route-level Now effective_routes(router), with the comment recording that it previously read .original_router.routes with no None fallback and "was correct only on >=0.139". It failed on this box before the change with AttributeError: 'NoneType' object has no attribute 'routes' — direct evidence of the divergence from the eager side.
    7 — no #15091 fixes in scope; version-independent static The guard is pure AST and resolves no FastAPI version. The three migrated tests now pass on the eager box, which is the version-independence claim.

    One correction to the issue's framing, established during the work. "Neither the include-time prefix= nor dependencies= 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 why test_roles_routes_registered.py prepends llc_router.prefix and 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 _walk reads getattr(container, "routes", []) where container is 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-quality failure this PR hit is worth recording. Lifting the resolver out of a test file put it under the strict autobot_shared/ mypy sweep for the first time — pyproject.toml excludes .*_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 into Dict[type, List[ast.AST]], which erases the element type), fixed with a typed _Buckets dataclass filled by isinstance, zero # type: ignore. Verified not a silent behaviour change: differential run over autobot-backend gave 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.py was 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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions