2 changed files with 269 additions and 3 deletions
@ -0,0 +1,244 @@ |
|||||
|
"""Tests for logging ResponseValidationError from streaming endpoint serializers. |
||||
|
|
||||
|
Regression tests for the bug where ResponseValidationError raised during |
||||
|
serialization of a yielded item was silently swallowed with no log output. |
||||
|
|
||||
|
Covers: |
||||
|
- SSE async generator |
||||
|
- SSE sync generator |
||||
|
- JSONL async generator |
||||
|
- JSONL sync generator |
||||
|
|
||||
|
For each variant, verifies: |
||||
|
1. Valid streams are unaffected (no spurious ERROR logs, correct body). |
||||
|
2. Invalid items trigger an ERROR-level log entry with exc_info attached. |
||||
|
3. The exception still propagates (is not swallowed). |
||||
|
""" |
||||
|
|
||||
|
import logging |
||||
|
from collections.abc import AsyncIterable, Iterable |
||||
|
|
||||
|
import pytest |
||||
|
from fastapi import FastAPI |
||||
|
from fastapi.exceptions import ResponseValidationError |
||||
|
from fastapi.responses import EventSourceResponse |
||||
|
from fastapi.testclient import TestClient |
||||
|
from pydantic import BaseModel |
||||
|
|
||||
|
|
||||
|
class Item(BaseModel): |
||||
|
id: int |
||||
|
name: str |
||||
|
|
||||
|
|
||||
|
# ── App setup ──────────────────────────────────────────────────────────────── |
||||
|
|
||||
|
app = FastAPI() |
||||
|
|
||||
|
|
||||
|
@app.get("/sse/valid", response_class=EventSourceResponse) |
||||
|
async def sse_valid() -> AsyncIterable[Item]: |
||||
|
yield Item(id=1, name="ok") |
||||
|
yield Item(id=2, name="also-ok") |
||||
|
|
||||
|
|
||||
|
@app.get("/sse/valid-sync", response_class=EventSourceResponse) |
||||
|
def sse_valid_sync() -> Iterable[Item]: |
||||
|
yield Item(id=1, name="ok") |
||||
|
yield Item(id=2, name="also-ok") |
||||
|
|
||||
|
|
||||
|
@app.get("/sse/invalid", response_class=EventSourceResponse) |
||||
|
async def sse_invalid() -> AsyncIterable[Item]: |
||||
|
# "id" must be an int; passing a non-coercible string causes |
||||
|
# ResponseValidationError during serialization. |
||||
|
yield {"id": "NOT_AN_INT", "name": "bad"} # type: ignore[misc] |
||||
|
|
||||
|
|
||||
|
@app.get("/sse/invalid-sync", response_class=EventSourceResponse) |
||||
|
def sse_invalid_sync() -> Iterable[Item]: |
||||
|
yield {"id": "NOT_AN_INT", "name": "bad"} # type: ignore[misc] |
||||
|
|
||||
|
|
||||
|
@app.get("/jsonl/valid") |
||||
|
async def jsonl_valid() -> AsyncIterable[Item]: |
||||
|
yield Item(id=1, name="ok") |
||||
|
yield Item(id=2, name="also-ok") |
||||
|
|
||||
|
|
||||
|
@app.get("/jsonl/valid-sync") |
||||
|
def jsonl_valid_sync() -> Iterable[Item]: |
||||
|
yield Item(id=1, name="ok") |
||||
|
yield Item(id=2, name="also-ok") |
||||
|
|
||||
|
|
||||
|
@app.get("/jsonl/invalid") |
||||
|
async def jsonl_invalid() -> AsyncIterable[Item]: |
||||
|
yield {"id": "NOT_AN_INT", "name": "bad"} # type: ignore[misc] |
||||
|
|
||||
|
|
||||
|
@app.get("/jsonl/invalid-sync") |
||||
|
def jsonl_invalid_sync() -> Iterable[Item]: |
||||
|
yield {"id": "NOT_AN_INT", "name": "bad"} # type: ignore[misc] |
||||
|
|
||||
|
|
||||
|
# ── Fixtures ────────────────────────────────────────────────────────────────── |
||||
|
|
||||
|
|
||||
|
@pytest.fixture(name="client") |
||||
|
def client_fixture(): |
||||
|
with TestClient(app, raise_server_exceptions=False) as c: |
||||
|
yield c |
||||
|
|
||||
|
|
||||
|
@pytest.fixture(name="raising_client") |
||||
|
def raising_client_fixture(): |
||||
|
"""Client that lets ResponseValidationError propagate into the test.""" |
||||
|
with TestClient(app, raise_server_exceptions=True) as c: |
||||
|
yield c |
||||
|
|
||||
|
|
||||
|
# ── Valid stream tests (no regressions) ────────────────────────────────────── |
||||
|
|
||||
|
|
||||
|
def test_sse_valid_stream_unaffected( |
||||
|
client: TestClient, caplog: pytest.LogCaptureFixture |
||||
|
): |
||||
|
"""Valid SSE streams must still produce correct output and zero ERROR logs.""" |
||||
|
with caplog.at_level(logging.ERROR, logger="fastapi"): |
||||
|
response = client.get("/sse/valid") |
||||
|
|
||||
|
assert response.status_code == 200 |
||||
|
assert "text/event-stream" in response.headers["content-type"] |
||||
|
data_lines = [ |
||||
|
line for line in response.text.splitlines() if line.startswith("data: ") |
||||
|
] |
||||
|
assert len(data_lines) == 2 |
||||
|
error_records = [r for r in caplog.records if r.levelno >= logging.ERROR] |
||||
|
assert error_records == [], ( |
||||
|
f"Unexpected ERROR logs on valid stream: {error_records}" |
||||
|
) |
||||
|
|
||||
|
|
||||
|
def test_jsonl_valid_stream_unaffected( |
||||
|
client: TestClient, caplog: pytest.LogCaptureFixture |
||||
|
): |
||||
|
"""Valid JSONL streams must still produce correct output and zero ERROR logs.""" |
||||
|
with caplog.at_level(logging.ERROR, logger="fastapi"): |
||||
|
response = client.get("/jsonl/valid") |
||||
|
|
||||
|
assert response.status_code == 200 |
||||
|
assert "application/jsonl" in response.headers["content-type"] |
||||
|
lines = [line for line in response.text.splitlines() if line.strip()] |
||||
|
assert len(lines) == 2 |
||||
|
error_records = [r for r in caplog.records if r.levelno >= logging.ERROR] |
||||
|
assert error_records == [], ( |
||||
|
f"Unexpected ERROR logs on valid stream: {error_records}" |
||||
|
) |
||||
|
|
||||
|
|
||||
|
def test_valid_sse_produces_no_error_logs( |
||||
|
client: TestClient, caplog: pytest.LogCaptureFixture |
||||
|
): |
||||
|
"""Sync valid SSE produces no spurious ERROR log.""" |
||||
|
with caplog.at_level(logging.ERROR, logger="fastapi"): |
||||
|
response = client.get("/sse/valid-sync") |
||||
|
assert response.status_code == 200 |
||||
|
error_records = [r for r in caplog.records if r.levelno >= logging.ERROR] |
||||
|
assert error_records == [] |
||||
|
|
||||
|
|
||||
|
def test_valid_jsonl_produces_no_error_logs( |
||||
|
client: TestClient, caplog: pytest.LogCaptureFixture |
||||
|
): |
||||
|
"""Sync valid JSONL produces no spurious ERROR log.""" |
||||
|
with caplog.at_level(logging.ERROR, logger="fastapi"): |
||||
|
response = client.get("/jsonl/valid-sync") |
||||
|
assert response.status_code == 200 |
||||
|
error_records = [r for r in caplog.records if r.levelno >= logging.ERROR] |
||||
|
assert error_records == [] |
||||
|
|
||||
|
|
||||
|
# ── Invalid item logging tests ──────────────────────────────────────────────── |
||||
|
|
||||
|
|
||||
|
def test_sse_invalid_item_is_logged( |
||||
|
client: TestClient, caplog: pytest.LogCaptureFixture |
||||
|
): |
||||
|
"""ResponseValidationError in async SSE serializer must emit an ERROR log with exc_info.""" |
||||
|
with caplog.at_level(logging.ERROR, logger="fastapi"): |
||||
|
client.get("/sse/invalid") |
||||
|
|
||||
|
error_records = [r for r in caplog.records if r.levelno >= logging.ERROR] |
||||
|
assert error_records, ( |
||||
|
"Expected at least one ERROR log record for SSE validation failure" |
||||
|
) |
||||
|
record = error_records[0] |
||||
|
assert record.exc_info is not None, "ERROR log must carry exc_info (full traceback)" |
||||
|
assert "ResponseValidationError" in record.getMessage() or issubclass( |
||||
|
record.exc_info[0], |
||||
|
ResponseValidationError, # type: ignore[index] |
||||
|
), "ERROR log message or exc_info must reference ResponseValidationError" |
||||
|
|
||||
|
|
||||
|
def test_jsonl_invalid_item_is_logged( |
||||
|
client: TestClient, caplog: pytest.LogCaptureFixture |
||||
|
): |
||||
|
"""ResponseValidationError in async JSONL serializer must emit an ERROR log with exc_info.""" |
||||
|
with caplog.at_level(logging.ERROR, logger="fastapi"): |
||||
|
client.get("/jsonl/invalid") |
||||
|
|
||||
|
error_records = [r for r in caplog.records if r.levelno >= logging.ERROR] |
||||
|
assert error_records, ( |
||||
|
"Expected at least one ERROR log record for JSONL validation failure" |
||||
|
) |
||||
|
record = error_records[0] |
||||
|
assert record.exc_info is not None, "ERROR log must carry exc_info (full traceback)" |
||||
|
assert "ResponseValidationError" in record.getMessage() or issubclass( |
||||
|
record.exc_info[0], |
||||
|
ResponseValidationError, # type: ignore[index] |
||||
|
), "ERROR log message or exc_info must reference ResponseValidationError" |
||||
|
|
||||
|
|
||||
|
def test_sse_invalid_item_logged_sync( |
||||
|
client: TestClient, caplog: pytest.LogCaptureFixture |
||||
|
): |
||||
|
"""ResponseValidationError in sync SSE serializer must emit an ERROR log with exc_info.""" |
||||
|
with caplog.at_level(logging.ERROR, logger="fastapi"): |
||||
|
client.get("/sse/invalid-sync") |
||||
|
|
||||
|
error_records = [r for r in caplog.records if r.levelno >= logging.ERROR] |
||||
|
assert error_records, "Expected ERROR log for sync SSE validation failure" |
||||
|
assert error_records[0].exc_info is not None |
||||
|
|
||||
|
|
||||
|
def test_jsonl_invalid_item_logged_sync( |
||||
|
client: TestClient, caplog: pytest.LogCaptureFixture |
||||
|
): |
||||
|
"""ResponseValidationError in sync JSONL serializer must emit an ERROR log with exc_info.""" |
||||
|
with caplog.at_level(logging.ERROR, logger="fastapi"): |
||||
|
client.get("/jsonl/invalid-sync") |
||||
|
|
||||
|
error_records = [r for r in caplog.records if r.levelno >= logging.ERROR] |
||||
|
assert error_records, "Expected ERROR log for sync JSONL validation failure" |
||||
|
assert error_records[0].exc_info is not None |
||||
|
|
||||
|
|
||||
|
# ── Exception propagation tests ─────────────────────────────────────────────── |
||||
|
|
||||
|
|
||||
|
def test_sse_invalid_item_propagates(raising_client: TestClient): |
||||
|
"""ResponseValidationError from async SSE must still propagate (not be swallowed). |
||||
|
|
||||
|
The SSE _producer task runs inside an anyio task group, so the error is |
||||
|
wrapped in an ExceptionGroup when it escapes the group boundary. |
||||
|
""" |
||||
|
# anyio task group wraps the sub-task exception in an ExceptionGroup |
||||
|
with pytest.raises((ResponseValidationError, ExceptionGroup)): # type: ignore[name-defined] # noqa: F821 |
||||
|
raising_client.get("/sse/invalid") |
||||
|
|
||||
|
|
||||
|
def test_jsonl_invalid_item_propagates(raising_client: TestClient): |
||||
|
"""ResponseValidationError from async JSONL must still propagate (not be swallowed).""" |
||||
|
with pytest.raises(ResponseValidationError): |
||||
|
raising_client.get("/jsonl/invalid") |
||||
Loading…
Reference in new issue