From 4e17d1d044b58d2bf4aa093c79045eb8514f3509 Mon Sep 17 00:00:00 2001 From: Declan Brady Date: Tue, 23 Jun 2026 14:13:41 -0400 Subject: [PATCH 1/2] fix(deps): cap fastapi <0.137.0 to stop OPTIONS preflight 500s FastAPI 0.137.0 made app.routes a tree containing _IncludedRouter wrapper objects that do not expose a .path attribute, instead of a flat list of routes that each have .path. The OTel FastAPI auto-instrumentation (injected at runtime by the observability operator, so not a dependency we can bump here) reads route.path on a Match.PARTIAL while building the request span. A browser CORS preflight (OPTIONS) has no declared endpoint, so it only PARTIAL-matches the _IncludedRouter, raising AttributeError in the ASGI layer (outside FastAPI's exception handlers) and returning HTTP 500. This blocks the Agents UI from loading. The version actually resolved by the workspace is controlled by the root pyproject's override-dependencies, which floored fastapi>=0.135.0 with no ceiling and so picked up 0.137+. Cap it (and the agentex package dep) at <0.137.0. Pinning up does not help: 0.137.1/0.137.2/0.138.0 keep _IncludedRouter and only add a new iter_route_contexts() helper the pinned injected instrumentation does not use. The starlette>=1.3.1 override keeps the existing CVE fixes regardless of the cap; resolution lands on 0.136.3. Add a regression test asserting every app.routes entry exposes .path and that OPTIONS-preflight route matching cannot crash, so a future fastapi bump that reintroduces the regression fails CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- agentex/pyproject.toml | 2 +- .../tests/unit/api/test_otel_route_compat.py | 55 +++++++++++++++++++ pyproject.toml | 6 +- uv.lock | 10 ++-- 4 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 agentex/tests/unit/api/test_otel_route_compat.py diff --git a/agentex/pyproject.toml b/agentex/pyproject.toml index c212ff94..cab3f41d 100644 --- a/agentex/pyproject.toml +++ b/agentex/pyproject.toml @@ -6,7 +6,7 @@ authors = [{ name = "Felix Su", email = "felix.su@scale.com" }] requires-python = ">=3.12,<3.13" readme = "README.md" dependencies = [ - "fastapi>=0.115.0", + "fastapi>=0.115.0,<0.137.0", # <0.137.0 keeps app.routes flat; 0.137.0's _IncludedRouter (no .path) crashes injected OTel instrumentation on OPTIONS preflights -> 500 "litellm>=1.84.0,<2", # >=1.84.0 clears CVE-2026-49468 (CRITICAL auth bypass) "python-dotenv>=1.2.2,<2", "temporalio>=1.18.0,<2", diff --git a/agentex/tests/unit/api/test_otel_route_compat.py b/agentex/tests/unit/api/test_otel_route_compat.py new file mode 100644 index 00000000..fb30313e --- /dev/null +++ b/agentex/tests/unit/api/test_otel_route_compat.py @@ -0,0 +1,55 @@ +""" +Regression test for the OPTIONS/CORS preflight 500 caused by FastAPI's +``_IncludedRouter`` change. + +FastAPI 0.137.0 made ``app.routes`` a tree that can contain ``_IncludedRouter`` +wrapper objects (one per ``include_router()`` call). Those wrappers do NOT +expose a ``.path`` attribute. The OpenTelemetry FastAPI auto-instrumentation +(injected at runtime by the observability operator, so it is not a dependency we +can bump here) reads ``route.path`` on a ``Match.PARTIAL`` while building the +request span. A browser CORS preflight (``OPTIONS``) has no declared endpoint, +so it only PARTIAL-matches the ``_IncludedRouter`` -> ``AttributeError`` raised +inside the ASGI layer (outside FastAPI's exception handlers) -> HTTP 500. + +The only lever we control is the FastAPI version we ship, so we pin +``fastapi < 0.137.0`` to keep ``app.routes`` a flat list of routes that all +expose ``.path``. Pinning *up* does not help: 0.137.1/0.137.2/0.138.0 keep the +``_IncludedRouter`` and only add a new ``iter_route_contexts()`` helper that the +pinned injected instrumentation does not use. + +If this test fails, FastAPI has been bumped to a version that reintroduces the +preflight-crash regression for the injected OTel instrumentation. +""" + +import pytest +from src.api.app import fastapi_app +from starlette.routing import Match + + +@pytest.mark.unit +class TestOtelRouteCompat: + def test_every_route_exposes_path(self): + """Every entry in app.routes must expose ``.path`` (what OTel reads).""" + missing = [ + type(route).__name__ + for route in fastapi_app.routes + if not hasattr(route, "path") + ] + assert not missing, ( + "Routes without a `.path` attribute would crash the injected OTel " + f"FastAPI instrumentation on OPTIONS preflights: {missing}. " + "This usually means FastAPI was bumped to >= 0.137.0." + ) + + def test_options_preflight_route_matching_does_not_crash(self): + """Simulate OTel `_get_route_details` over an OPTIONS preflight scope.""" + scope = { + "type": "http", + "method": "OPTIONS", + "path": "/agents", + "headers": [], + } + for route in fastapi_app.routes: + match, _ = route.matches(scope) + if match in (Match.FULL, Match.PARTIAL): + assert route.path is not None diff --git a/pyproject.toml b/pyproject.toml index 47e41ae2..6123bc43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,8 +27,12 @@ environments = [ # carries the fixes for CVE-2026-48818 / CVE-2026-54283 (and CVE-2025-62727). # python-multipart>=0.0.32 clears CVE-2026-53539. Keep these floors at the patched # minimums so a re-resolve can't regress below them. +# fastapi is capped <0.137.0: 0.137.0 made app.routes a tree of _IncludedRouter +# wrappers (no .path), which crashes the injected OTel FastAPI instrumentation on +# OPTIONS/CORS preflights (-> 500). 0.137.1/0.137.2/0.138.0 do not restore the flat +# list. The starlette>=1.3.1 floor below keeps the CVE fixes regardless of this cap. override-dependencies = [ - "fastapi>=0.135.0", + "fastapi>=0.135.0,<0.137.0", "starlette>=1.3.1", "httpx[http2]>=0.28.1,<0.29", "langchain-core>=1.3.3", diff --git a/uv.lock b/uv.lock index 9a6909ab..8c84e98b 100644 --- a/uv.lock +++ b/uv.lock @@ -16,7 +16,7 @@ members = [ "agentex-backend", ] overrides = [ - { name = "fastapi", specifier = ">=0.135.0" }, + { name = "fastapi", specifier = ">=0.135.0,<0.137.0" }, { name = "httpx", extras = ["http2"], specifier = ">=0.28.1,<0.29" }, { name = "langchain-core", specifier = ">=1.3.3" }, { name = "mako", specifier = ">=1.3.12" }, @@ -116,7 +116,7 @@ requires-dist = [ { name = "datadog", specifier = ">=0.52.1" }, { name = "ddtrace", specifier = ">=3.13.0" }, { name = "docker", specifier = ">=7.1.0,<8" }, - { name = "fastapi", specifier = ">=0.115.0" }, + { name = "fastapi", specifier = ">=0.115.0,<0.137.0" }, { name = "httpx", extras = ["http2"], specifier = ">=0.27.2" }, { name = "json-log-formatter", specifier = ">=1.1.1" }, { name = "kubernetes-asyncio", specifier = ">=31.1.0,<32" }, @@ -728,7 +728,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.137.1" +version = "0.136.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -737,9 +737,9 @@ dependencies = [ { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/b1/e5b92c59d2c37817e77c1a8c2fc1f79cdcc04c68253e5406b43e3204cba7/fastapi-0.137.1.tar.gz", hash = "sha256:822360704230d9533d8d9475399613525968aa2f0b5bd2a3ccc9f18c88fd541c", size = 408293, upload-time = "2026-06-15T11:28:20.79Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/35/380b9a5922f4340e51c309cde09e5bd32e62f02302971bee30dc15aa0624/fastapi-0.137.1-py3-none-any.whl", hash = "sha256:64f6983c59e45c4b9fdc44e57cb8035c2451ee91ea8e8ec042aca37de7cf6b69", size = 121877, upload-time = "2026-06-15T11:28:19.523Z" }, + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, ] [[package]] From 87b22850b18be0447a3312dd00c469f325b1f896 Mon Sep 17 00:00:00 2001 From: Declan Brady Date: Tue, 23 Jun 2026 15:28:09 -0400 Subject: [PATCH 2/2] test: assert OPTIONS-preflight test matches at least one route Greptile flagged a vacuous-pass risk: the loop only asserts inside the match branch, so if no route matched the scope the test would pass even on a broken FastAPI version. Count matches and assert matched > 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- agentex/tests/unit/api/test_otel_route_compat.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/agentex/tests/unit/api/test_otel_route_compat.py b/agentex/tests/unit/api/test_otel_route_compat.py index fb30313e..019cc4d9 100644 --- a/agentex/tests/unit/api/test_otel_route_compat.py +++ b/agentex/tests/unit/api/test_otel_route_compat.py @@ -49,7 +49,14 @@ def test_options_preflight_route_matching_does_not_crash(self): "path": "/agents", "headers": [], } + matched = 0 for route in fastapi_app.routes: match, _ = route.matches(scope) if match in (Match.FULL, Match.PARTIAL): + matched += 1 assert route.path is not None + assert matched > 0, ( + "No route matched the OPTIONS /agents preflight scope, so the " + "`.path` assertion never ran. The test would pass vacuously even " + "on a broken FastAPI version. Pick a path served by app.routes." + )