Skip to content

Commit 22a3018

Browse files
committed
fix(api): return structured 4xx for missing provider credentials
Fixes #7628
1 parent 3e071fc commit 22a3018

8 files changed

Lines changed: 195 additions & 7 deletions

File tree

openbb_platform/core/openbb_core/api/app_loader.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
from openbb_core.api.exception_handlers import ExceptionHandlers
66
from openbb_core.app.model.abstract.error import OpenBBError
77
from openbb_core.app.router import RouterLoader
8-
from openbb_core.provider.utils.errors import EmptyDataError, UnauthorizedError
8+
from openbb_core.provider.utils.errors import (
9+
EmptyDataError,
10+
MissingCredentialError,
11+
UnauthorizedError,
12+
)
913
from pydantic import ValidationError
1014

1115

@@ -39,5 +43,8 @@ def add_exception_handlers(app: FastAPI):
3943
app.exception_handlers[ValidationError] = ExceptionHandlers.validation
4044
app.exception_handlers[ResponseValidationError] = ExceptionHandlers.validation
4145
app.exception_handlers[OpenBBError] = ExceptionHandlers.openbb
46+
app.exception_handlers[MissingCredentialError] = (
47+
ExceptionHandlers.missing_credential
48+
)
4249
app.exception_handlers[EmptyDataError] = ExceptionHandlers.empty_data
4350
app.exception_handlers[UnauthorizedError] = ExceptionHandlers.unauthorized

openbb_platform/core/openbb_core/api/exception_handlers.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@
1111
from fastapi.responses import JSONResponse, Response
1212
from openbb_core.app.model.abstract.error import OpenBBError
1313
from openbb_core.env import Env
14-
from openbb_core.provider.utils.errors import EmptyDataError, UnauthorizedError
14+
from openbb_core.provider.utils.errors import (
15+
EmptyDataError,
16+
MissingCredentialError,
17+
UnauthorizedError,
18+
)
1519
from pydantic import ValidationError
1620

1721
logger = logging.getLogger("uvicorn.error")
@@ -119,6 +123,26 @@ async def openbb(_: Request, error: OpenBBError):
119123
detail=str(error.original),
120124
)
121125

126+
@staticmethod
127+
async def missing_credential(_: Request, error: MissingCredentialError):
128+
"""Exception handler for MissingCredentialError.
129+
130+
A missing provider credential is an expected configuration state, not a
131+
server fault. Return a structured non-5xx response so clients (e.g. the
132+
Workspace widget validator) can distinguish an unconfigured provider
133+
from an internal error.
134+
"""
135+
return await ExceptionHandlers._handle(
136+
exception=error,
137+
status_code=400,
138+
detail={
139+
"code": "missing_credentials",
140+
"provider": error.provider,
141+
"credential": error.credential,
142+
"message": error.message,
143+
},
144+
)
145+
122146
@staticmethod
123147
async def empty_data(_: Request, error: EmptyDataError):
124148
"""Exception handler for EmptyDataError."""

openbb_platform/core/openbb_core/provider/query_executor.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from openbb_core.provider.abstract.fetcher import Fetcher
77
from openbb_core.provider.abstract.provider import Provider
88
from openbb_core.provider.registry import Registry, RegistryLoader
9+
from openbb_core.provider.utils.errors import MissingCredentialError
910
from pydantic import SecretStr
1011

1112

@@ -53,9 +54,15 @@ def filter_credentials(
5354
if require_credentials:
5455
website = provider.website or ""
5556
extra_msg = f" Check {website} to get it." if website else ""
56-
raise OpenBBError(
57-
f"Missing credential '{c}'.{extra_msg} Refer to the documentation for setting provider "
58-
"credentials at https://docs.openbb.co/platform/settings/user_settings/api_keys."
57+
message = (
58+
f"Missing credential '{c}'.{extra_msg} Refer to the"
59+
" documentation for setting provider credentials at"
60+
" https://docs.openbb.co/platform/settings/user_settings/api_keys."
61+
)
62+
raise MissingCredentialError(
63+
provider=provider.name.lower(),
64+
credential=c,
65+
message=message,
5966
)
6067
else:
6168
filtered_credentials[c] = secret

openbb_platform/core/openbb_core/provider/utils/errors.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,36 @@ def __init__(
1414
super().__init__(self.message)
1515

1616

17+
class MissingCredentialError(OpenBBError):
18+
"""Exception raised when a required provider credential is not configured."""
19+
20+
def __init__(
21+
self,
22+
provider: str,
23+
credential: str,
24+
message: str | None = None,
25+
):
26+
"""Initialize the exception.
27+
28+
Parameters
29+
----------
30+
provider : str
31+
Name of the provider, e.g. "eia".
32+
credential : str
33+
Name of the missing credential, e.g. "eia_api_key".
34+
message : str | None
35+
Optional human-readable message. Defaults to a standard message.
36+
"""
37+
self.provider = provider
38+
self.credential = credential
39+
self.message = message or (
40+
f"Missing credential '{credential}' for provider '{provider}'. "
41+
"Refer to the documentation for setting provider credentials at "
42+
"https://docs.openbb.co/platform/settings/user_settings/api_keys."
43+
)
44+
super().__init__(self.message)
45+
46+
1747
class UnauthorizedError(OpenBBError):
1848
"""Exception raised for an unauthorized provider request response."""
1949

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Test API exception handlers."""
2+
3+
import asyncio
4+
import json
5+
6+
from openbb_core.api.exception_handlers import ExceptionHandlers
7+
from openbb_core.provider.utils.errors import MissingCredentialError
8+
9+
10+
def get_response_detail(response):
11+
"""Return the JSON response detail."""
12+
return json.loads(response.body)["detail"]
13+
14+
15+
def test_missing_credential_returns_structured_400():
16+
"""Return a structured 4xx when a provider credential is missing."""
17+
error = MissingCredentialError(provider="eia", credential="eia_api_key")
18+
19+
response = asyncio.run(ExceptionHandlers.missing_credential(None, error))
20+
21+
assert response.status_code == 400
22+
detail = get_response_detail(response)
23+
assert detail["code"] == "missing_credentials"
24+
assert detail["provider"] == "eia"
25+
assert detail["credential"] == "eia_api_key"
26+
assert "eia_api_key" in detail["message"]
27+
28+
29+
def test_missing_credential_message_preserved():
30+
"""Keep the provider-provided message when one is supplied."""
31+
error = MissingCredentialError(
32+
provider="eia",
33+
credential="eia_api_key",
34+
message="API_KEY_MISSING -> No api_key was supplied.",
35+
)
36+
37+
response = asyncio.run(ExceptionHandlers.missing_credential(None, error))
38+
39+
detail = get_response_detail(response)
40+
assert detail["message"] == "API_KEY_MISSING -> No api_key was supplied."
41+
42+
43+
def test_missing_credential_is_openbb_error():
44+
"""MissingCredentialError must remain an OpenBBError for backward compatibility."""
45+
error = MissingCredentialError(provider="eia", credential="eia_api_key")
46+
47+
assert isinstance(error, Exception)
48+
assert str(error) == error.message

openbb_platform/core/tests/provider/test_query_executor.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from openbb_core.provider.abstract.fetcher import Fetcher
1010
from openbb_core.provider.abstract.provider import Provider
1111
from openbb_core.provider.query_executor import QueryExecutor
12+
from openbb_core.provider.utils.errors import MissingCredentialError
1213
from pydantic import SecretStr
1314

1415

@@ -75,9 +76,12 @@ def test_filter_credentials_missing_require(mock_query_executor):
7576
provider.credentials = ["test_provider_api_key"]
7677
credentials = {"other_api_key": SecretStr("12345")}
7778

78-
with pytest.raises(OpenBBError, match="Missing credential"):
79+
with pytest.raises(MissingCredentialError) as exc_info:
7980
mock_query_executor.filter_credentials(credentials, provider, True)
8081

82+
assert exc_info.value.provider == "test"
83+
assert exc_info.value.credential == "test_provider_api_key"
84+
8185

8286
def test_filter_credentials_empty_require(mock_query_executor):
8387
"""Test if the proper error is raised when a credential is missing."""
@@ -88,9 +92,12 @@ def test_filter_credentials_empty_require(mock_query_executor):
8892
"other_api_key": SecretStr("12345"),
8993
}
9094

91-
with pytest.raises(OpenBBError, match="Missing credential"):
95+
with pytest.raises(MissingCredentialError) as exc_info:
9296
mock_query_executor.filter_credentials(credentials, provider, True)
9397

98+
assert exc_info.value.provider == "test"
99+
assert exc_info.value.credential == "test_provider_api_key"
100+
94101

95102
def test_filter_credentials_missing_dont_require(mock_query_executor):
96103
"""Test if the proper error is raised when a credential is missing."""

openbb_platform/providers/eia/openbb_us_eia/utils/helpers.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from async_lru import alru_cache
66
from openbb_core.app.model.abstract.error import OpenBBError
7+
from openbb_core.provider.utils.errors import MissingCredentialError
78

89
if TYPE_CHECKING:
910
from pandas import ExcelFile
@@ -15,6 +16,12 @@ async def response_callback(response, _):
1516
res = await response.json()
1617
code = res.get("error", {}).get("code", "")
1718
msg = res.get("error", {}).get("message", "An invalid api_key was supplied.")
19+
if code == "API_KEY_MISSING":
20+
raise MissingCredentialError(
21+
provider="eia",
22+
credential="eia_api_key",
23+
message=f"{code} -> {msg}",
24+
)
1825
raise OpenBBError(f"{code} -> {msg}")
1926
return await response.json()
2027

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Tests for EIA provider helpers."""
2+
3+
import asyncio
4+
5+
import pytest
6+
from openbb_core.provider.utils.errors import MissingCredentialError
7+
from openbb_us_eia.utils.helpers import response_callback
8+
9+
10+
class MockResponse:
11+
"""Mock aiohttp response."""
12+
13+
def __init__(self, status, payload):
14+
"""Initialize the mock response."""
15+
self.status = status
16+
self._payload = payload
17+
18+
async def json(self):
19+
"""Return the JSON payload."""
20+
return self._payload
21+
22+
23+
def test_response_callback_missing_api_key_raises_missing_credential():
24+
"""Raise MissingCredentialError when the EIA API reports API_KEY_MISSING."""
25+
response = MockResponse(
26+
status=403,
27+
payload={
28+
"error": {
29+
"code": "API_KEY_MISSING",
30+
"message": "No api_key was supplied.",
31+
}
32+
},
33+
)
34+
35+
with pytest.raises(MissingCredentialError) as exc_info:
36+
asyncio.run(response_callback(response, None))
37+
38+
assert exc_info.value.provider == "eia"
39+
assert exc_info.value.credential == "eia_api_key"
40+
41+
42+
def test_response_callback_other_403_raises_generic_error():
43+
"""Keep generic OpenBBError for non-missing-key 403 responses."""
44+
response = MockResponse(
45+
status=403,
46+
payload={
47+
"error": {
48+
"code": "UNAUTHORIZED",
49+
"message": "Invalid key.",
50+
}
51+
},
52+
)
53+
54+
with pytest.raises(Exception) as exc_info:
55+
asyncio.run(response_callback(response, None))
56+
57+
assert not isinstance(exc_info.value, MissingCredentialError)
58+
assert "UNAUTHORIZED" in str(exc_info.value)

0 commit comments

Comments
 (0)