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..019cc4d9 --- /dev/null +++ b/agentex/tests/unit/api/test_otel_route_compat.py @@ -0,0 +1,62 @@ +""" +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": [], + } + 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." + ) 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]]