From 6497645ec41fa18f251bdfcdd5b0cb43facf6b55 Mon Sep 17 00:00:00 2001 From: artcashin <64659740+artcashin@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:24:32 -0400 Subject: [PATCH 1/2] fix(core): map missing inputs, timeouts and non-JSON responses to 4xx/5xx ExceptionHandlers.exception read `error.errors` without calling it. For a pydantic ValidationError that is a bound method, not a list, so a required provider QueryParams field missing from the request fell through to "500 Unexpected Error -> ValidationError". It now calls errors(); only a missing QueryParams field is a 422, so a provider Data model failing on an upstream response that dropped a key stays a 500. The detail goes through jsonable_encoder, because the error input can hold dates. TimeoutError now maps to 504 and json.JSONDecodeError (an upstream that answered with HTML or an empty body) to 502, each with a clear detail. AppLoader registers both by type: the Exception catch-all runs outside the CORS middleware, so its responses carry no CORS headers and a browser sees a CORS failure instead of the status. Co-Authored-By: Claude Opus 5.5 --- .../core/openbb_core/api/app_loader.py | 4 ++ .../openbb_core/api/exception_handlers.py | 23 +++++- .../core/tests/api/test_app_loader.py | 37 ++++++++++ .../core/tests/api/test_exception_handlers.py | 71 +++++++++++++++++++ 4 files changed, 133 insertions(+), 2 deletions(-) diff --git a/openbb_platform/core/openbb_core/api/app_loader.py b/openbb_platform/core/openbb_core/api/app_loader.py index 42d561bc6409..88041eae23af 100644 --- a/openbb_platform/core/openbb_core/api/app_loader.py +++ b/openbb_platform/core/openbb_core/api/app_loader.py @@ -1,5 +1,6 @@ """App loader module.""" +import json from typing import Any from fastapi import APIRouter, FastAPI @@ -95,6 +96,9 @@ def openapi() -> dict[str, Any]: def add_exception_handlers(app: FastAPI): """Add exception handlers.""" app.add_exception_handler(Exception, ExceptionHandlers.exception) + # Handled inside the CORS middleware, unlike the Exception catch-all. + app.add_exception_handler(TimeoutError, ExceptionHandlers.exception) + app.add_exception_handler(json.JSONDecodeError, ExceptionHandlers.exception) app.add_exception_handler(ValidationError, ExceptionHandlers.validation) # ty: ignore[invalid-argument-type] app.add_exception_handler(ResponseValidationError, ExceptionHandlers.validation) # ty: ignore[invalid-argument-type] app.add_exception_handler(OpenBBError, ExceptionHandlers.openbb) # ty: ignore[invalid-argument-type] diff --git a/openbb_platform/core/openbb_core/api/exception_handlers.py b/openbb_platform/core/openbb_core/api/exception_handlers.py index 85abf39d9da6..eb0646c5f4ee 100644 --- a/openbb_platform/core/openbb_core/api/exception_handlers.py +++ b/openbb_platform/core/openbb_core/api/exception_handlers.py @@ -1,10 +1,12 @@ """Exception handlers module.""" +import json import logging from collections.abc import Iterable from typing import Any from fastapi import Request +from fastapi.encoders import jsonable_encoder from fastapi.exceptions import ResponseValidationError from fastapi.responses import JSONResponse, Response from pydantic import ValidationError @@ -35,7 +37,22 @@ async def _handle(exception: Exception, status_code: int, detail: Any): @staticmethod async def exception(_: Request, error: Exception) -> JSONResponse: """Exception handler for Base Exception.""" + if isinstance(error, TimeoutError): + return await ExceptionHandlers._handle( + exception=error, + status_code=504, + detail=str(error) + or "The upstream data source did not respond in time.", + ) + if isinstance(error, json.JSONDecodeError): + return await ExceptionHandlers._handle( + exception=error, + status_code=502, + detail="The data source returned a response that is not valid JSON.", + ) errors = error.errors if hasattr(error, "errors") else error + if callable(errors): + errors = errors() if errors: if isinstance(errors, ValueError): @@ -45,13 +62,15 @@ async def exception(_: Request, error: Exception) -> JSONResponse: detail=errors.args, ) # Required parameters are missing and is not handled by ValidationError. - if isinstance(errors, Iterable): + if isinstance(errors, Iterable) and "QueryParams" in getattr( + error, "title", "" + ): for err in errors: if err.get("type") == "missing": return await ExceptionHandlers._handle( exception=error, status_code=422, - detail={**err}, + detail=jsonable_encoder(err), ) return await ExceptionHandlers._handle( exception=error, diff --git a/openbb_platform/core/tests/api/test_app_loader.py b/openbb_platform/core/tests/api/test_app_loader.py index 810374d22b40..90e6c084eb9a 100644 --- a/openbb_platform/core/tests/api/test_app_loader.py +++ b/openbb_platform/core/tests/api/test_app_loader.py @@ -1,6 +1,11 @@ """Tests for ``AppLoader``'s OpenAPI tag rewriting.""" +import json + +import pytest from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.testclient import TestClient from openbb_core.api.app_loader import AppLoader @@ -145,3 +150,35 @@ def test_swagger_ui_opens_with_every_tag_collapsed(): from openbb_core.api.rest_api import app assert (app.swagger_ui_parameters or {}).get("docExpansion") == "none" + + +@pytest.mark.parametrize( + "error, status", + [ + (TimeoutError("slow"), 504), + (json.JSONDecodeError("Expecting value", "", 0), 502), + ], +) +def test_timeout_and_invalid_json_responses_keep_cors_headers( + monkeypatch, error, status +): + """Handlers registered by type run inside the CORS middleware. + + The ``Exception`` catch-all runs outside it, so a browser client would see + a CORS failure instead of the status. + """ + monkeypatch.setattr( + "openbb_core.api.exception_handlers.Env", type("E", (), {"DEBUG_MODE": False}) + ) + app = FastAPI() + app.add_middleware(CORSMiddleware, allow_origins=["*"]) + AppLoader.add_exception_handlers(app) + + @app.get("/fail") + async def fail(): + raise error + + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/fail", headers={"Origin": "https://example.com"}) + assert response.status_code == status + assert response.headers.get("access-control-allow-origin") == "*" diff --git a/openbb_platform/core/tests/api/test_exception_handlers.py b/openbb_platform/core/tests/api/test_exception_handlers.py index 813f48929e0d..2acfd10acbb8 100644 --- a/openbb_platform/core/tests/api/test_exception_handlers.py +++ b/openbb_platform/core/tests/api/test_exception_handlers.py @@ -1,6 +1,7 @@ """Tests for openbb_core.api.exception_handlers.""" import json +from datetime import date from unittest.mock import MagicMock import pytest @@ -71,12 +72,82 @@ async def test_exception_handler_iterable_missing(monkeypatch): ) class HasErrors(Exception): + title = "EquityQuoteQueryParams" errors = [{"type": "missing", "loc": ("symbol",)}] resp = await ExceptionHandlers.exception(_make_request(), HasErrors()) assert resp.status_code == 422 +@pytest.mark.asyncio +async def test_exception_handler_missing_query_params_field_is_422(monkeypatch): + """A required provider QueryParams field absent from the request is a 422.""" + monkeypatch.setattr( + "openbb_core.api.exception_handlers.Env", type("E", (), {"DEBUG_MODE": False}) + ) + + class ItemQueryParams(BaseModel): + start_date: date + code: str + + try: + ItemQueryParams(start_date=date(2024, 1, 2)) # type: ignore[call-arg] + except ValidationError as ve: + err = ve + + # The input holds a date, so the detail must be JSON-encoded to serialize. + resp = await ExceptionHandlers.exception(_make_request(), err) + assert resp.status_code == 422 + detail = json.loads(resp.body)["detail"] + assert (detail["type"], detail["loc"]) == ("missing", ["code"]) + assert detail["input"] == {"start_date": "2024-01-02"} + + +@pytest.mark.asyncio +async def test_exception_handler_missing_data_field_stays_500(monkeypatch): + """A Data model missing a field is the upstream response's fault, not the client's.""" + monkeypatch.setattr( + "openbb_core.api.exception_handlers.Env", type("E", (), {"DEBUG_MODE": False}) + ) + + class ItemData(BaseModel): + value: float + + try: + ItemData() # type: ignore[call-arg] + except ValidationError as ve: + err = ve + + resp = await ExceptionHandlers.exception(_make_request(), err) + assert resp.status_code == 500 + + +@pytest.mark.asyncio +async def test_exception_handler_timeout_is_504(monkeypatch): + monkeypatch.setattr( + "openbb_core.api.exception_handlers.Env", type("E", (), {"DEBUG_MODE": False}) + ) + resp = await ExceptionHandlers.exception(_make_request(), TimeoutError("slow")) + assert (resp.status_code, json.loads(resp.body)["detail"]) == (504, "slow") + + resp = await ExceptionHandlers.exception(_make_request(), TimeoutError()) + assert resp.status_code == 504 + assert "did not respond in time" in json.loads(resp.body)["detail"] + + +@pytest.mark.asyncio +async def test_exception_handler_invalid_json_is_502(monkeypatch): + monkeypatch.setattr( + "openbb_core.api.exception_handlers.Env", type("E", (), {"DEBUG_MODE": False}) + ) + err = json.JSONDecodeError("Expecting value", "blocked", 0) + resp = await ExceptionHandlers.exception(_make_request(), err) + assert resp.status_code == 502 + assert json.loads(resp.body)["detail"] == ( + "The data source returned a response that is not valid JSON." + ) + + @pytest.mark.asyncio async def test_validation_handler_query_params_routed_to_query_loc(monkeypatch): monkeypatch.setattr( From 6d5da02ce00f09fc32ef7354d347da7079347336 Mon Sep 17 00:00:00 2001 From: artcashin <64659740+artcashin@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:45:10 -0400 Subject: [PATCH 2/2] fix(core): treat a missing field on any QueryParams subclass as client input Provider extensions name query models freely (e.g. BalanceQuery), so the title check alone left their missing inputs at 500. Co-Authored-By: Claude Opus 5.5 --- .../openbb_core/api/exception_handlers.py | 18 ++++++++++++-- .../core/tests/api/test_exception_handlers.py | 24 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/openbb_platform/core/openbb_core/api/exception_handlers.py b/openbb_platform/core/openbb_core/api/exception_handlers.py index eb0646c5f4ee..4db791e66283 100644 --- a/openbb_platform/core/openbb_core/api/exception_handlers.py +++ b/openbb_platform/core/openbb_core/api/exception_handlers.py @@ -18,6 +18,19 @@ logger = logging.getLogger("uvicorn.error") +def _is_query_model(title: str) -> bool: + """Whether a validation error's model is a QueryParams class (client input), not a Data model.""" + from openbb_core.provider.abstract.query_params import QueryParams + + classes = [QueryParams] + while classes: + cls = classes.pop() + if cls.__name__ == title: + return True + classes.extend(cls.__subclasses__()) + return False + + class ExceptionHandlers: """Exception handlers.""" @@ -62,8 +75,9 @@ async def exception(_: Request, error: Exception) -> JSONResponse: detail=errors.args, ) # Required parameters are missing and is not handled by ValidationError. - if isinstance(errors, Iterable) and "QueryParams" in getattr( - error, "title", "" + title = getattr(error, "title", "") + if isinstance(errors, Iterable) and ( + "QueryParams" in title or _is_query_model(title) ): for err in errors: if err.get("type") == "missing": diff --git a/openbb_platform/core/tests/api/test_exception_handlers.py b/openbb_platform/core/tests/api/test_exception_handlers.py index 2acfd10acbb8..2f54e74c2529 100644 --- a/openbb_platform/core/tests/api/test_exception_handlers.py +++ b/openbb_platform/core/tests/api/test_exception_handlers.py @@ -103,6 +103,30 @@ class ItemQueryParams(BaseModel): assert detail["input"] == {"start_date": "2024-01-02"} +@pytest.mark.asyncio +async def test_exception_handler_missing_field_of_any_query_params_subclass_is_422( + monkeypatch, +): + """A QueryParams subclass whose name lacks "QueryParams" is still client input.""" + from openbb_core.provider.abstract.query_params import QueryParams + + monkeypatch.setattr( + "openbb_core.api.exception_handlers.Env", type("E", (), {"DEBUG_MODE": False}) + ) + + class BalanceQuery(QueryParams): + symbol: str + + try: + BalanceQuery() # type: ignore[call-arg] + except ValidationError as ve: + err = ve + + resp = await ExceptionHandlers.exception(_make_request(), err) + assert resp.status_code == 422 + assert json.loads(resp.body)["detail"]["type"] == "missing" + + @pytest.mark.asyncio async def test_exception_handler_missing_data_field_stays_500(monkeypatch): """A Data model missing a field is the upstream response's fault, not the client's."""