From 09e356b64247e6624ed8ca4031df7ae9a474137d Mon Sep 17 00:00:00 2001 From: Jeroen Ubbink Date: Tue, 25 Aug 2026 16:23:31 +0200 Subject: [PATCH] fix(api): answer a request-validation failure with a 422 that serializes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_011cKHVF5LA7TR5QuYi6ErLM --- routstr/core/exceptions.py | 43 ++++++++++++++++ routstr/core/main.py | 8 ++- tests/integration/conftest.py | 4 ++ .../test_admin_pricing_rate_validation.py | 50 +++++++++++++++++++ 4 files changed, 104 insertions(+), 1 deletion(-) diff --git a/routstr/core/exceptions.py b/routstr/core/exceptions.py index 9e4f2ae6..fd3cfefa 100644 --- a/routstr/core/exceptions.py +++ b/routstr/core/exceptions.py @@ -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") diff --git a/routstr/core/main.py b/routstr/core/main.py index 979f5cdb..b89fa64d 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -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) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index aa10a81c..265c73c9 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -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 diff --git a/tests/integration/test_admin_pricing_rate_validation.py b/tests/integration/test_admin_pricing_rate_validation.py index 376d2ca0..ed609051 100644 --- a/tests/integration/test_admin_pricing_rate_validation.py +++ b/tests/integration/test_admin_pricing_rate_validation.py @@ -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