Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions openbb_platform/core/openbb_core/api/app_loader.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""App loader module."""

import json
from typing import Any

from fastapi import APIRouter, FastAPI
Expand Down Expand Up @@ -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]
Expand Down
37 changes: 35 additions & 2 deletions openbb_platform/core/openbb_core/api/exception_handlers.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -16,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."""

Expand All @@ -35,7 +50,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):
Expand All @@ -45,13 +75,16 @@ async def exception(_: Request, error: Exception) -> JSONResponse:
detail=errors.args,
)
# Required parameters are missing and is not handled by ValidationError.
if isinstance(errors, Iterable):
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":
return await ExceptionHandlers._handle(
exception=error,
status_code=422,
detail={**err},
detail=jsonable_encoder(err),
)
return await ExceptionHandlers._handle(
exception=error,
Expand Down
37 changes: 37 additions & 0 deletions openbb_platform/core/tests/api/test_app_loader.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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", "<html>", 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") == "*"
95 changes: 95 additions & 0 deletions openbb_platform/core/tests/api/test_exception_handlers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for openbb_core.api.exception_handlers."""

import json
from datetime import date
from unittest.mock import MagicMock

import pytest
Expand Down Expand Up @@ -71,12 +72,106 @@ 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_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."""
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", "<html>blocked</html>", 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(
Expand Down