mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
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:
co-authored by
Claude Opus 5
parent
754f5d1a77
commit
09e356b642
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user