Skip to content

Commit 296495d

Browse files
authored
Merge pull request #2380 from thepetk/fix-metrics-with-proxy
RSPEED-3444: fix REST API metrics middleware route discovery and root_path handling
2 parents 5a11423 + b5a3069 commit 296495d

2 files changed

Lines changed: 71 additions & 8 deletions

File tree

src/app/main.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
from fastapi.middleware.cors import CORSMiddleware
1111
from fastapi.responses import JSONResponse
1212
from ogx_client import APIConnectionError, AsyncOgxClient
13-
from starlette.routing import Mount, Route, WebSocketRoute
13+
from fastapi.routing import iter_route_contexts
14+
1415
from starlette.types import ASGIApp, Message, Receive, Scope, Send
1516

1617
import version
@@ -212,7 +213,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
212213
# requests with the full prefixed path (/api/lightspeed/v1/infer) but
213214
# app_routes_paths contains only application-level paths (/v1/infer).
214215
# Strip the prefix so the path check and metric labels match the routes.
215-
root_path = scope.get("root_path", "")
216+
root_path: str = app.root_path
216217
path: str = scope["path"]
217218
if root_path and path.startswith(root_path + "/"):
218219
path = path[len(root_path) :]
@@ -292,9 +293,9 @@ async def send_wrapper(message: Message) -> None:
292293
routers.include_routers(app)
293294

294295
app_routes_paths = [
295-
route.path
296-
for route in app.routes
297-
if isinstance(route, (Mount, Route, WebSocketRoute))
296+
rc.original_route.path
297+
for rc in iter_route_contexts(app.routes)
298+
if hasattr(rc.original_route, "path") and rc.original_route.path
298299
]
299300

300301
# Register pure ASGI middlewares. Middleware execution order is the reverse of

tests/unit/app/test_main_middleware.py

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,14 @@
99
from pytest_mock import MockerFixture
1010
from starlette.types import Message, Receive, Scope, Send
1111

12-
from app.main import GlobalExceptionMiddleware, RestApiMetricsMiddleware
12+
from app.main import (
13+
GlobalExceptionMiddleware,
14+
RestApiMetricsMiddleware,
15+
app_routes_paths,
16+
)
17+
from app.main import (
18+
app as fastapi_app,
19+
)
1320
from models.api.responses.error import InternalServerErrorResponse
1421

1522

@@ -189,6 +196,7 @@ async def test_rest_api_metrics_strips_root_path(
189196
) -> None:
190197
"""Middleware must strip root_path so prefixed requests still match routes."""
191198
mocker.patch("app.main.app_routes_paths", ["/v1/infer"])
199+
mocker.patch.object(fastapi_app, "root_path", "/api/lightspeed")
192200
mock_measure_duration = mocker.patch(
193201
"app.main.recording.measure_response_duration", return_value=nullcontext()
194202
)
@@ -201,9 +209,9 @@ async def ok_app(_scope: Scope, _receive: Receive, send: Send) -> None:
201209
middleware = RestApiMetricsMiddleware(ok_app)
202210
collector = _ResponseCollector()
203211

204-
# Simulate 3scale forwarding /api/lightspeed/v1/infer with root_path set.
212+
# Simulate 3scale forwarding /api/lightspeed/v1/infer — scope carries no root_path.
205213
await middleware(
206-
_make_scope("/api/lightspeed/v1/infer", root_path="/api/lightspeed"),
214+
_make_scope("/api/lightspeed/v1/infer"),
207215
_noop_receive,
208216
collector,
209217
)
@@ -241,3 +249,57 @@ async def ok_app(_scope: Scope, _receive: Receive, send: Send) -> None:
241249
assert collector.status_code == 200
242250
mock_measure_duration.assert_called_once_with("/v1/infer")
243251
mock_record_call.assert_called_once_with("/v1/infer", 200)
252+
253+
254+
@pytest.mark.asyncio
255+
async def test_rest_api_metrics_uses_app_root_path_not_scope(
256+
mocker: MockerFixture,
257+
) -> None:
258+
"""Middleware must read root_path from app.root_path, not scope["root_path"].
259+
260+
The scope carries an empty root_path while app.root_path holds the real prefix.
261+
If the middleware reads from the scope it will not strip the prefix, the path
262+
will not match any route, and no metric will be recorded — causing both
263+
mock_measure_duration and mock_record_call assertions to fail.
264+
"""
265+
mocker.patch("app.main.app_routes_paths", ["/v1/infer"])
266+
mocker.patch.object(fastapi_app, "root_path", "/api/lightspeed")
267+
mock_measure_duration = mocker.patch(
268+
"app.main.recording.measure_response_duration", return_value=nullcontext()
269+
)
270+
mock_record_call = mocker.patch("app.main.recording.record_rest_api_call")
271+
272+
async def ok_app(_scope: Scope, _receive: Receive, send: Send) -> None:
273+
await send({"type": "http.response.start", "status": 200, "headers": []})
274+
await send({"type": "http.response.body", "body": b"ok"})
275+
276+
middleware = RestApiMetricsMiddleware(ok_app)
277+
collector = _ResponseCollector()
278+
279+
# scope["root_path"] is explicitly empty while app.root_path is "/api/lightspeed".
280+
# The middleware must use app.root_path to strip the prefix correctly.
281+
scope = _make_scope("/api/lightspeed/v1/infer")
282+
scope["root_path"] = ""
283+
await middleware(scope, _noop_receive, collector)
284+
285+
assert collector.status_code == 200
286+
mock_measure_duration.assert_called_once_with("/v1/infer")
287+
mock_record_call.assert_called_once_with("/v1/infer", 200)
288+
289+
290+
# ---------------------------------------------------------------------------
291+
# app_routes_paths population
292+
# ---------------------------------------------------------------------------
293+
294+
295+
def test_app_routes_paths_contains_application_routes() -> None:
296+
"""app_routes_paths must include routes registered via include_router.
297+
298+
FastAPI >= 0.137 stores included routers as _IncludedRouter objects that
299+
the old isinstance(route, (Mount, Route, WebSocketRoute)) filter silently
300+
drops. iter_route_contexts() resolves them correctly. If this test fails
301+
with only 4 entries (the FastAPI built-ins), the fix has been reverted.
302+
"""
303+
assert "/liveness" in app_routes_paths
304+
assert "/readiness" in app_routes_paths
305+
assert len(app_routes_paths) > 4

0 commit comments

Comments
 (0)