fix(api): answer a request-validation failure with a 422 that serializes

`json` parses the bare `Infinity`/`NaN` literals into real floats, so the
pricing edge rejects them correctly — but pydantic echoes the offending value
back in the error's `input` field, and JSONResponse encodes with
`allow_nan=False`. Serializing the 422 then raised "Out of range float values
are not JSON compliant" and the reply escaped as a 500, reporting a client's bad
rate as a server fault. The request already failed closed, so no row was ever
written.

Handle RequestValidationError explicitly and render non-finite floats as text,
which covers every endpoint rather than the pricing edge alone. The integration
app now copies the main app's exception handlers so a failing request fails the
way it does in production.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cKHVF5LA7TR5QuYi6ErLM
This commit is contained in:
Jeroen Ubbink
2026-08-25 16:23:31 +02:00
co-authored by Claude Opus 5
parent 754f5d1a77
commit 09e356b642
4 changed files with 104 additions and 1 deletions
+43
View File
@@ -1,4 +1,8 @@
import math
from fastapi import Request
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from .logging import get_logger
@@ -61,6 +65,45 @@ async def http_exception_handler(request: Request, exc: Exception) -> JSONRespon
return JSONResponse(status_code=status_code, content=content)
def json_compliant(value: object) -> object:
"""Render non-finite floats as text so a reply carrying them can serialize.
``json`` parses the bare ``NaN``/``Infinity``/``-Infinity`` literals into
real floats, so a request body — and a stored row written from one — may
hold one anywhere. ``JSONResponse`` encodes with ``allow_nan=False`` and
raises on them, which would turn a reply that merely *quotes* the offending
value into a 500.
"""
if isinstance(value, float) and not math.isfinite(value):
return repr(value)
if isinstance(value, dict):
return {key: json_compliant(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [json_compliant(item) for item in value]
return value
async def validation_exception_handler(
request: Request, exc: Exception
) -> JSONResponse:
"""Answer a request-validation failure with a 422 that always serializes.
Pydantic echoes the rejected value back in each error's ``input`` field. A
non-finite float there breaks the encoder, so the 422 escapes as a 500 and
reports a client's bad rate as a server fault.
"""
request_id = getattr(request.state, "request_id", "unknown")
errors = exc.errors() if isinstance(exc, RequestValidationError) else []
return JSONResponse(
status_code=422,
content={
"detail": json_compliant(jsonable_encoder(errors)),
"request_id": request_id,
},
)
async def general_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Handle general exceptions and include request ID in response."""
request_id = getattr(request.state, "request_id", "unknown")
+7 -1
View File
@@ -4,6 +4,7 @@ from pathlib import Path
from typing import AsyncGenerator
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
@@ -33,7 +34,11 @@ from ..upstream.litellm_routing import configure_litellm
from ..wallet import periodic_payout, periodic_refund_sweep, periodic_routstr_fee_payout
from .admin import admin_router
from .db import create_session, init_db, run_migrations
from .exceptions import general_exception_handler, http_exception_handler
from .exceptions import (
general_exception_handler,
http_exception_handler,
validation_exception_handler,
)
from .logging import get_logger, setup_logging
from .middleware import LoggingMiddleware
from .not_found import _NOT_FOUND_HTML, not_found_catch_all # noqa: F401
@@ -289,6 +294,7 @@ app.add_middleware(LoggingMiddleware)
# Add exception handlers
app.add_exception_handler(HTTPException, http_exception_handler) # type: ignore
app.add_exception_handler(RequestValidationError, validation_exception_handler)
app.add_exception_handler(Exception, general_exception_handler)
+4
View File
@@ -508,6 +508,10 @@ async def integration_app(
# Copy all routes from the main app
test_app.router = app.router
# ...and its exception handlers, so a request that fails here fails the way
# it would in production rather than escaping as a bare exception.
test_app.exception_handlers.update(app.exception_handlers)
# Override the get_session dependency
async def override_get_session() -> AsyncGenerator[AsyncSession, None]:
yield integration_session
@@ -209,3 +209,53 @@ async def test_oversized_integer_price_is_rejected(
assert resp.status_code == 422
assert await integration_session.get(ModelRow, ("huge-price", provider_id)) is None
@pytest.mark.integration
@pytest.mark.asyncio
async def test_non_finite_literal_price_is_rejected(
integration_client: AsyncClient, integration_session: AsyncSession
) -> None:
"""A bare ``Infinity``/``NaN`` literal gets the same 422 as any other rate.
``json`` accepts both literals, so the edge sees a real float and rejects
it — but pydantic echoes the offending value back in the error's ``input``
field, and the response encoder runs with ``allow_nan=False``. Serializing
that reply raised "Out of range float values are not JSON compliant", so the
422 escaped as a 500 and reported a client's bad rate as a server fault.
"""
provider_id = await _make_provider(integration_session)
for literal in ("Infinity", "-Infinity", "NaN"):
resp = await integration_client.post(
f"/admin/api/upstream-providers/{provider_id}/models",
headers={**_admin_headers(), "Content-Type": "application/json"},
content=_raw_model_body(provider_id, "odd-price", literal),
)
assert resp.status_code == 422, literal
assert (
await integration_session.get(ModelRow, ("odd-price", provider_id)) is None
)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_non_finite_literal_price_is_rejected_in_batch_override(
integration_client: AsyncClient, integration_session: AsyncSession
) -> None:
"""The batch path shares the same carrier, so it must answer 422 too."""
provider_id = await _make_provider(integration_session)
resp = await integration_client.post(
f"/admin/api/upstream-providers/{provider_id}/batch-override",
headers={**_admin_headers(), "Content-Type": "application/json"},
content=(
'{"models": ['
+ _raw_model_body(provider_id, "odd-batch", "Infinity")
+ "]}"
),
)
assert resp.status_code == 422
assert await integration_session.get(ModelRow, ("odd-batch", provider_id)) is None