|
12 | 12 | from models.responses import InternalServerErrorResponse |
13 | 13 |
|
14 | 14 |
|
15 | | -def _make_scope(path: str = "/test") -> dict: |
| 15 | +def _make_scope(path: str = "/test", root_path: str = "") -> Scope: |
16 | 16 | """Build a minimal HTTP ASGI scope.""" |
17 | | - return { |
| 17 | + scope: Scope = { |
18 | 18 | "type": "http", |
19 | 19 | "method": "GET", |
20 | 20 | "path": path, |
21 | 21 | "query_string": b"", |
22 | 22 | "headers": [], |
23 | 23 | } |
| 24 | + if root_path: |
| 25 | + scope["root_path"] = root_path |
| 26 | + return scope |
24 | 27 |
|
25 | 28 |
|
26 | 29 | async def _noop_receive() -> dict: |
@@ -175,3 +178,59 @@ async def failing_app(_scope: Scope, _receive: Receive, _send: Send) -> None: |
175 | 178 | mock_metrics.response_duration_seconds.labels.assert_called_once_with("/v1/infer") |
176 | 179 | mock_metrics.rest_api_calls_total.labels.assert_called_once_with("/v1/infer", 500) |
177 | 180 | mock_metrics.rest_api_calls_total.labels.return_value.inc.assert_called_once() |
| 181 | + |
| 182 | + |
| 183 | +@pytest.mark.asyncio |
| 184 | +async def test_rest_api_metrics_strips_root_path( |
| 185 | + mocker: MockerFixture, |
| 186 | +) -> None: |
| 187 | + """Middleware must strip root_path so prefixed requests still match routes.""" |
| 188 | + mocker.patch("app.main.app_routes_paths", ["/v1/infer"]) |
| 189 | + mock_metrics = mocker.patch("app.main.metrics") |
| 190 | + |
| 191 | + async def ok_app(_scope: Scope, _receive: Receive, send: Send) -> None: |
| 192 | + await send({"type": "http.response.start", "status": 200, "headers": []}) |
| 193 | + await send({"type": "http.response.body", "body": b"ok"}) |
| 194 | + |
| 195 | + middleware = RestApiMetricsMiddleware(ok_app) |
| 196 | + collector = _ResponseCollector() |
| 197 | + |
| 198 | + # Simulate 3scale forwarding /api/lightspeed/v1/infer with root_path set. |
| 199 | + await middleware( |
| 200 | + _make_scope("/api/lightspeed/v1/infer", root_path="/api/lightspeed"), |
| 201 | + _noop_receive, |
| 202 | + collector, |
| 203 | + ) |
| 204 | + |
| 205 | + assert collector.status_code == 200 |
| 206 | + # Metrics labels should use the stripped path, not the full prefixed path. |
| 207 | + mock_metrics.response_duration_seconds.labels.assert_called_once_with("/v1/infer") |
| 208 | + mock_metrics.rest_api_calls_total.labels.assert_called_once_with("/v1/infer", 200) |
| 209 | + mock_metrics.rest_api_calls_total.labels.return_value.inc.assert_called_once() |
| 210 | + |
| 211 | + |
| 212 | +@pytest.mark.asyncio |
| 213 | +async def test_rest_api_metrics_no_root_path_unchanged( |
| 214 | + mocker: MockerFixture, |
| 215 | +) -> None: |
| 216 | + """Without root_path, middleware behaves as before.""" |
| 217 | + mocker.patch("app.main.app_routes_paths", ["/v1/infer"]) |
| 218 | + mock_metrics = mocker.patch("app.main.metrics") |
| 219 | + |
| 220 | + async def ok_app(_scope: Scope, _receive: Receive, send: Send) -> None: |
| 221 | + await send({"type": "http.response.start", "status": 200, "headers": []}) |
| 222 | + await send({"type": "http.response.body", "body": b"ok"}) |
| 223 | + |
| 224 | + middleware = RestApiMetricsMiddleware(ok_app) |
| 225 | + collector = _ResponseCollector() |
| 226 | + |
| 227 | + await middleware( |
| 228 | + _make_scope("/v1/infer"), |
| 229 | + _noop_receive, |
| 230 | + collector, |
| 231 | + ) |
| 232 | + |
| 233 | + assert collector.status_code == 200 |
| 234 | + mock_metrics.response_duration_seconds.labels.assert_called_once_with("/v1/infer") |
| 235 | + mock_metrics.rest_api_calls_total.labels.assert_called_once_with("/v1/infer", 200) |
| 236 | + mock_metrics.rest_api_calls_total.labels.return_value.inc.assert_called_once() |
0 commit comments