You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

244 lines
9.1 KiB

"""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")