Skip to content

fix(core): map missing inputs, timeouts and non-JSON responses to 4xx/5xx - #7751

Open
artcashin wants to merge 3 commits into
openbq-org:developfrom
artcashin:fix/core-exception-status-codes
Open

artcashin wants to merge 3 commits into
openbq-org:developfrom
artcashin:fix/core-exception-status-codes

Conversation

@artcashin

@artcashin artcashin commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Problem

ExceptionHandlers.exception gives REST clients the wrong status in three cases:

  • A missing provider input is a 500. When a provider's required QueryParams field is absent from the request, validation passes the ValidationError on to exception. That handler reads error.errors without calling it. For a pydantic ValidationError that is a bound method, not a list, so its "missing" branch never runs and the client gets 500 Unexpected Error -> ValidationError.
  • An upstream timeout is a 500: Unexpected Error -> TimeoutError.
  • An upstream body that is not JSON is a 422. json.JSONDecodeError is a ValueError, so it takes the ValueError branch and the client is told its own request was invalid, when the upstream sent HTML or an empty body.

The Exception catch-all also runs in Starlette's ServerErrorMiddleware, outside CORSMiddleware, so its responses carry no CORS headers. A browser client sees a CORS failure instead of the status.

Change

  • exception calls errors() when it is callable. The 422 "missing" branch now applies only when the error's title contains QueryParams. With errors() called, a provider Data model failing on an upstream response that dropped a key would otherwise become a 422 that blames the client; it stays a 500. The detail goes through jsonable_encoder, because the error's input can hold dates that JSONResponse cannot serialize.
  • TimeoutError maps to 504, with the error's message or "The upstream data source did not respond in time."
  • json.JSONDecodeError maps to 502: "The data source returned a response that is not valid JSON."
  • AppLoader.add_exception_handlers registers TimeoutError and json.JSONDecodeError by type. Handlers registered by type run inside the CORS middleware, so these responses keep their CORS headers.

Tests

New in tests/api/test_exception_handlers.py:

  • a missing QueryParams field with a date in the input gives a 422 with a JSON-encoded detail;
  • a missing Data field stays a 500;
  • TimeoutError gives a 504, with and without a message;
  • JSONDecodeError gives a 502.

New in tests/api/test_app_loader.py: behind CORSMiddleware, the 504 and 502 responses carry access-control-allow-origin (parametrized, two cases).

Five of these fail without the change. The Data-field test passes either way; it checks that the narrowed 422 branch keeps that case a 500. test_exception_handler_iterable_missing now gives its fake error a QueryParams title, because the 422 branch requires one; its assertion is unchanged. The two files pass 25/25, and openbb_platform/core/tests passes 2,254 with 16 skipped (Python 3.12).

Results from a deployment

OpenBQ's Docker image has run these two files as a build-time patch over openbb-core 2.0.1, which matches develop for both files.

  • Missing inputs. In a sweep that called every widget, 82 provider-required inputs answered 500 Unexpected Error -> ValidationError, out of 100 500s for that class of request. With the patch, a request without a required provider input answers 422 with {"type": "missing", "loc": ["code"], ...}. We checked this on /cftc/cot, with start_date in the request so the error's input holds a date.
  • Timeouts. EIA requests that timed out upstream answered 500 Unexpected Error -> TimeoutError. They now answer 504.
  • Non-JSON responses. Nasdaq endpoints that returned a non-JSON page answered 422. They now answer 502.
  • The overlay's own tests. Our test module for these core patches runs against the full installed app. It checks the 422, 504 and 502 statuses and their CORS headers through the real rest_api app. It passes 11/11; on unpatched 2.0.1 it had 7 failures and 1 error. The module also covers fix(core): mark required provider-only query params required in the widget #7752 and feat(core): optional per-provider deadline for commands #7753.

Not included

🤖 Generated with Claude Code

…/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 <noreply@anthropic.com>
artcashin and others added 2 commits October 9, 2026 19:45
…t 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 <noreply@anthropic.com>

@dlee-bq dlee-bq left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @artcashin, thanks for the PR. This is good for the most part, but there's some handling needing further consideration:

  • requests.Timeout isn’t a subclass of TimeoutError, and Nasdaq and others use requests, so those timeouts are still 500s.
  • When amake_request gets a blocked page served as text/html, aiohttp raises ContentTypeError, not JSONDecodeError. That is probably the most common “non-JSON upstream” case, and it isn’t covered.
  • Python 3.10: core declares >=3.10, but on 3.10 asyncio.TimeoutError is not the builtin TimeoutError. aiohttp timeouts would stay 500 there.
  • The 422 body has a different shape: a single dict, with loc: ["code"] and pydantic’s url. FastAPI’s own 422s, and the existing validation path, return a list with loc: ["query", ...]. Clients that parse FastAPI-style 422s will see the difference, and only the first missing field is reported.

Do we need to register additional handling in the Python Interface's Exception Handlers?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants