|
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 ( |
32 | 33 | AvailabilityOutcome, |
33 | 34 | AvailabilityReason, |
|
37 | 38 | from tests.consts import SAMPLE_REQUEST, SUCCESSFUL_CHAT_RESPONSE |
38 | 39 |
|
39 | 40 |
|
| 41 | +@contextlib.contextmanager |
| 42 | +def _capture_logs(): |
| 43 | + """Capture raw loguru records emitted within the block. |
| 44 | +
|
| 45 | + Each captured item is a loguru ``Message`` whose ``.record`` dict exposes |
| 46 | + ``message`` / ``level`` / ``exception`` / ``extra`` — lets tests assert on |
| 47 | + log content, attached tracebacks, and contextvar-bound fields. |
| 48 | + """ |
| 49 | + records = [] |
| 50 | + sink_id = loguru_logger.add(records.append, level="DEBUG", format="{message}") |
| 51 | + try: |
| 52 | + yield records |
| 53 | + finally: |
| 54 | + loguru_logger.remove(sink_id) |
| 55 | + |
| 56 | + |
| 57 | +def _proxy_error_records(records): |
| 58 | + return [ |
| 59 | + item.record |
| 60 | + for item in records |
| 61 | + if item.record["level"].name == "ERROR" |
| 62 | + and "Failed to proxy request" in item.record["message"] |
| 63 | + ] |
| 64 | + |
| 65 | + |
40 | 66 | def _latency_count(spy, result: PrometheusResult, req=SAMPLE_REQUEST) -> float: |
41 | 67 | return spy.histogram_count( |
42 | 68 | "chat_completion_latency", |
@@ -1067,7 +1093,7 @@ async def test_stream_completion_400_non_rate_limit_error( |
1067 | 1093 | received_chunks[0] |
1068 | 1094 | == b'data: {"code": 400, "error": "Upstream service returned an error"}\n\n' |
1069 | 1095 | ) |
1070 | | - mock_logger.error.assert_called_once() |
| 1096 | + mock_logger.opt.return_value.error.assert_called_once() |
1071 | 1097 | metrics_spy.assert_only({"chat_completion_latency", "chat_availability"}) |
1072 | 1098 | assert _latency_count(metrics_spy, PrometheusResult.ERROR) == 1 |
1073 | 1099 |
|
@@ -1097,7 +1123,7 @@ async def test_stream_completion_429_non_rate_limit_error( |
1097 | 1123 | received_chunks[0] |
1098 | 1124 | == b'data: {"code": 429, "error": "Upstream service returned an error"}\n\n' |
1099 | 1125 | ) |
1100 | | - mock_logger.error.assert_called_once() |
| 1126 | + mock_logger.opt.return_value.error.assert_called_once() |
1101 | 1127 | metrics_spy.assert_only({"chat_completion_latency", "chat_availability"}) |
1102 | 1128 | assert _latency_count(metrics_spy, PrometheusResult.ERROR) == 1 |
1103 | 1129 |
|
@@ -1151,7 +1177,7 @@ async def test_stream_completion_429_invalid_json( |
1151 | 1177 | received_chunks[0] |
1152 | 1178 | == b'data: {"code": 429, "error": "Upstream service returned an error"}\n\n' |
1153 | 1179 | ) |
1154 | | - mock_logger.error.assert_called_once() |
| 1180 | + mock_logger.opt.return_value.error.assert_called_once() |
1155 | 1181 | metrics_spy.assert_only({"chat_completion_latency", "chat_availability"}) |
1156 | 1182 | assert _latency_count(metrics_spy, PrometheusResult.ERROR) == 1 |
1157 | 1183 |
|
@@ -1520,3 +1546,73 @@ async def test_get_completion_sanitizes_response_surrogates(mocker): |
1520 | 1546 | assert "\ud83e" not in data["choices"][0]["message"]["content"] |
1521 | 1547 | assert data["choices"][0]["message"]["content"].startswith("done ") |
1522 | 1548 | _httpx_encode_json(data) # must not raise |
| 1549 | + |
| 1550 | + |
| 1551 | +async def test_get_completion_empty_message_transport_error_is_diagnosable(mocker): |
| 1552 | + """Regression for the prod 502s that logged a bare ``Failed to proxy request:``. |
| 1553 | +
|
| 1554 | + A transport error with no ``.response`` and an empty ``str()`` (e.g. |
| 1555 | + ``RemoteProtocolError("")``) must still produce a diagnosable ERROR line: |
| 1556 | + the exception type + repr in the message, the traceback attached, and the |
| 1557 | + request-identifying fields bound via ``contextualize(**log_fields)``. |
| 1558 | + """ |
| 1559 | + mock_client = AsyncMock() |
| 1560 | + mock_client.post.side_effect = httpx.RemoteProtocolError("") |
| 1561 | + mocker.patch("mlpa.core.completions.get_http_client", return_value=mock_client) |
| 1562 | + mocker.patch.object(env, "MLPA_DEBUG", False) |
| 1563 | + |
| 1564 | + with _capture_logs() as records: |
| 1565 | + with pytest.raises(HTTPException) as exc_info: |
| 1566 | + await get_completion(SAMPLE_REQUEST) |
| 1567 | + |
| 1568 | + assert exc_info.value.status_code == 502 |
| 1569 | + |
| 1570 | + proxy_errors = _proxy_error_records(records) |
| 1571 | + assert len(proxy_errors) == 1 |
| 1572 | + rec = proxy_errors[0] |
| 1573 | + # Exception type is named, and the message is NOT the old blank form. |
| 1574 | + assert "RemoteProtocolError" in rec["message"] |
| 1575 | + assert not rec["message"].rstrip().endswith("Failed to proxy request:") |
| 1576 | + # Traceback attached via logger.opt(exception=e). |
| 1577 | + assert rec["exception"] is not None |
| 1578 | + assert rec["exception"].type is httpx.RemoteProtocolError |
| 1579 | + # Request fields bound on the record (queryable as record.extra.*). |
| 1580 | + assert rec["extra"]["user"] == SAMPLE_REQUEST.user |
| 1581 | + assert rec["extra"]["model"] == SAMPLE_REQUEST.model |
| 1582 | + assert rec["extra"]["service_type"] == SAMPLE_REQUEST.service_type |
| 1583 | + |
| 1584 | + |
| 1585 | +async def test_stream_mid_stream_error_binds_request_fields( |
| 1586 | + mocker, mock_request, metrics_spy |
| 1587 | +): |
| 1588 | + """Streaming blind-spot regression. |
| 1589 | +
|
| 1590 | + An error raised mid-SSE-stream (after MLPA already returned 200) must still |
| 1591 | + log with the request fields bound — proving the ``contextualize`` scope set |
| 1592 | + inside ``stream_completion`` survives generator iteration, unlike the |
| 1593 | + middleware scope which has already exited by the time the body iterates. |
| 1594 | + """ |
| 1595 | + role_chunk = ( |
| 1596 | + b'data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n' |
| 1597 | + ) |
| 1598 | + |
| 1599 | + async def _failing_aiter_bytes(): |
| 1600 | + yield role_chunk |
| 1601 | + raise httpx.RemoteProtocolError("") |
| 1602 | + |
| 1603 | + _patch_mock_stream_client(mocker, _failing_aiter_bytes) |
| 1604 | + mocker.patch.object(env, "MLPA_DEBUG", False) |
| 1605 | + |
| 1606 | + with _capture_logs() as records: |
| 1607 | + received = [c async for c in stream_completion(SAMPLE_REQUEST, mock_request)] |
| 1608 | + |
| 1609 | + assert any(b'"error"' in chunk for chunk in received) |
| 1610 | + |
| 1611 | + proxy_errors = _proxy_error_records(records) |
| 1612 | + assert len(proxy_errors) == 1 |
| 1613 | + rec = proxy_errors[0] |
| 1614 | + assert "RemoteProtocolError" in rec["message"] |
| 1615 | + assert rec["exception"] is not None |
| 1616 | + assert rec["extra"]["user"] == SAMPLE_REQUEST.user |
| 1617 | + assert rec["extra"]["model"] == SAMPLE_REQUEST.model |
| 1618 | + assert rec["extra"]["service_type"] == SAMPLE_REQUEST.service_type |
0 commit comments