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