|
28 | 28 | LITELLM_HEADER_RESPONSE_DURATION_MS, |
29 | 29 | env, |
30 | 30 | ) |
| 31 | +from mlpa.core.logger import logger as loguru_logger |
31 | 32 | from mlpa.core.prometheus_metrics import PrometheusRejectionReason, PrometheusResult |
32 | 33 | from tests.consts import SAMPLE_REQUEST, SUCCESSFUL_CHAT_RESPONSE |
33 | 34 |
|
34 | 35 |
|
| 36 | +@contextlib.contextmanager |
| 37 | +def _capture_logs(): |
| 38 | + """Capture raw loguru records emitted within the block. |
| 39 | +
|
| 40 | + Each captured item is a loguru ``Message`` whose ``.record`` dict exposes |
| 41 | + ``message`` / ``level`` / ``exception`` / ``extra`` — lets tests assert on |
| 42 | + log content, attached tracebacks, and contextvar-bound fields. |
| 43 | + """ |
| 44 | + records = [] |
| 45 | + sink_id = loguru_logger.add(records.append, level="DEBUG", format="{message}") |
| 46 | + try: |
| 47 | + yield records |
| 48 | + finally: |
| 49 | + loguru_logger.remove(sink_id) |
| 50 | + |
| 51 | + |
| 52 | +def _proxy_error_records(records): |
| 53 | + return [ |
| 54 | + item.record |
| 55 | + for item in records |
| 56 | + if item.record["level"].name == "ERROR" |
| 57 | + and "Failed to proxy request" in item.record["message"] |
| 58 | + ] |
| 59 | + |
| 60 | + |
35 | 61 | def _latency_count(spy, result: PrometheusResult, req=SAMPLE_REQUEST) -> float: |
36 | 62 | return spy.histogram_count( |
37 | 63 | "chat_completion_latency", |
@@ -940,7 +966,7 @@ async def test_stream_completion_400_non_rate_limit_error( |
940 | 966 | received_chunks[0] |
941 | 967 | == b'data: {"code": 400, "error": "Upstream service returned an error"}\n\n' |
942 | 968 | ) |
943 | | - mock_logger.error.assert_called_once() |
| 969 | + mock_logger.opt.return_value.error.assert_called_once() |
944 | 970 | metrics_spy.assert_only({"chat_completion_latency"}) |
945 | 971 | assert _latency_count(metrics_spy, PrometheusResult.ERROR) == 1 |
946 | 972 |
|
@@ -970,7 +996,7 @@ async def test_stream_completion_429_non_rate_limit_error( |
970 | 996 | received_chunks[0] |
971 | 997 | == b'data: {"code": 429, "error": "Upstream service returned an error"}\n\n' |
972 | 998 | ) |
973 | | - mock_logger.error.assert_called_once() |
| 999 | + mock_logger.opt.return_value.error.assert_called_once() |
974 | 1000 | metrics_spy.assert_only({"chat_completion_latency"}) |
975 | 1001 | assert _latency_count(metrics_spy, PrometheusResult.ERROR) == 1 |
976 | 1002 |
|
@@ -1022,7 +1048,7 @@ async def test_stream_completion_429_invalid_json( |
1022 | 1048 | received_chunks[0] |
1023 | 1049 | == b'data: {"code": 429, "error": "Upstream service returned an error"}\n\n' |
1024 | 1050 | ) |
1025 | | - mock_logger.error.assert_called_once() |
| 1051 | + mock_logger.opt.return_value.error.assert_called_once() |
1026 | 1052 | metrics_spy.assert_only({"chat_completion_latency"}) |
1027 | 1053 | assert _latency_count(metrics_spy, PrometheusResult.ERROR) == 1 |
1028 | 1054 |
|
@@ -1373,3 +1399,73 @@ async def test_get_completion_sanitizes_response_surrogates(mocker): |
1373 | 1399 | assert "\ud83e" not in data["choices"][0]["message"]["content"] |
1374 | 1400 | assert data["choices"][0]["message"]["content"].startswith("done ") |
1375 | 1401 | _httpx_encode_json(data) # must not raise |
| 1402 | + |
| 1403 | + |
| 1404 | +async def test_get_completion_empty_message_transport_error_is_diagnosable(mocker): |
| 1405 | + """Regression for the prod 502s that logged a bare ``Failed to proxy request:``. |
| 1406 | +
|
| 1407 | + A transport error with no ``.response`` and an empty ``str()`` (e.g. |
| 1408 | + ``RemoteProtocolError("")``) must still produce a diagnosable ERROR line: |
| 1409 | + the exception type + repr in the message, the traceback attached, and the |
| 1410 | + request-identifying fields bound via ``contextualize(**log_fields)``. |
| 1411 | + """ |
| 1412 | + mock_client = AsyncMock() |
| 1413 | + mock_client.post.side_effect = httpx.RemoteProtocolError("") |
| 1414 | + mocker.patch("mlpa.core.completions.get_http_client", return_value=mock_client) |
| 1415 | + mocker.patch.object(env, "MLPA_DEBUG", False) |
| 1416 | + |
| 1417 | + with _capture_logs() as records: |
| 1418 | + with pytest.raises(HTTPException) as exc_info: |
| 1419 | + await get_completion(SAMPLE_REQUEST) |
| 1420 | + |
| 1421 | + assert exc_info.value.status_code == 502 |
| 1422 | + |
| 1423 | + proxy_errors = _proxy_error_records(records) |
| 1424 | + assert len(proxy_errors) == 1 |
| 1425 | + rec = proxy_errors[0] |
| 1426 | + # Exception type is named, and the message is NOT the old blank form. |
| 1427 | + assert "RemoteProtocolError" in rec["message"] |
| 1428 | + assert not rec["message"].rstrip().endswith("Failed to proxy request:") |
| 1429 | + # Traceback attached via logger.opt(exception=e). |
| 1430 | + assert rec["exception"] is not None |
| 1431 | + assert rec["exception"].type is httpx.RemoteProtocolError |
| 1432 | + # Request fields bound on the record (queryable as record.extra.*). |
| 1433 | + assert rec["extra"]["user"] == SAMPLE_REQUEST.user |
| 1434 | + assert rec["extra"]["model"] == SAMPLE_REQUEST.model |
| 1435 | + assert rec["extra"]["service_type"] == SAMPLE_REQUEST.service_type |
| 1436 | + |
| 1437 | + |
| 1438 | +async def test_stream_mid_stream_error_binds_request_fields( |
| 1439 | + mocker, mock_request, metrics_spy |
| 1440 | +): |
| 1441 | + """Streaming blind-spot regression. |
| 1442 | +
|
| 1443 | + An error raised mid-SSE-stream (after MLPA already returned 200) must still |
| 1444 | + log with the request fields bound — proving the ``contextualize`` scope set |
| 1445 | + inside ``stream_completion`` survives generator iteration, unlike the |
| 1446 | + middleware scope which has already exited by the time the body iterates. |
| 1447 | + """ |
| 1448 | + role_chunk = ( |
| 1449 | + b'data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n' |
| 1450 | + ) |
| 1451 | + |
| 1452 | + async def _failing_aiter_bytes(): |
| 1453 | + yield role_chunk |
| 1454 | + raise httpx.RemoteProtocolError("") |
| 1455 | + |
| 1456 | + _patch_mock_stream_client(mocker, _failing_aiter_bytes) |
| 1457 | + mocker.patch.object(env, "MLPA_DEBUG", False) |
| 1458 | + |
| 1459 | + with _capture_logs() as records: |
| 1460 | + received = [c async for c in stream_completion(SAMPLE_REQUEST, mock_request)] |
| 1461 | + |
| 1462 | + assert any(b'"error"' in chunk for chunk in received) |
| 1463 | + |
| 1464 | + proxy_errors = _proxy_error_records(records) |
| 1465 | + assert len(proxy_errors) == 1 |
| 1466 | + rec = proxy_errors[0] |
| 1467 | + assert "RemoteProtocolError" in rec["message"] |
| 1468 | + assert rec["exception"] is not None |
| 1469 | + assert rec["extra"]["user"] == SAMPLE_REQUEST.user |
| 1470 | + assert rec["extra"]["model"] == SAMPLE_REQUEST.model |
| 1471 | + assert rec["extra"]["service_type"] == SAMPLE_REQUEST.service_type |
0 commit comments