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
17 changes: 12 additions & 5 deletions src/openai/_exceptions.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Any, Optional, cast
from typing import TYPE_CHECKING, Optional, cast
from typing_extensions import Literal

import httpx2

from ._utils import is_dict
from ._models import construct_type
from .types.shared.oauth_error_code import OAuthErrorCode

if TYPE_CHECKING:
Expand Down Expand Up @@ -69,9 +68,17 @@ def __init__(self, message: str, request: httpx2.Request, *, body: object | None
self.body = body

if is_dict(body):
self.code = cast(Any, construct_type(type_=Optional[str], value=body.get("code")))
self.param = cast(Any, construct_type(type_=Optional[str], value=body.get("param")))
self.type = cast(Any, construct_type(type_=str, value=body.get("type")))
code = body.get("code")
param = body.get("param")
error_type = body.get("type")

# The API documents `error.code`, `error.param` and `error.type` as
# strings, but some servers (including api.openai.com under load, and
# OpenAI-compatible gateways) send integer codes. Coerce to `str` so
# the runtime value always matches the annotation.
self.code = str(code) if code is not None else None
self.param = str(param) if param is not None else None
self.type = str(error_type) if error_type is not None else None
else:
self.code = None
self.param = None
Expand Down
40 changes: 40 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1153,6 +1153,25 @@ def test_invalid_retry_after_date_does_not_mask_status_error(self, respx2_mock:

assert route.call_count == 1

@pytest.mark.respx2(base_url=base_url)
def test_api_status_error_code_is_coerced_to_str(self, respx2_mock: MockRouter, client: OpenAI) -> None:
respx2_mock.get("/foo").mock(
return_value=httpx2.Response(
400,
json={"error": {"code": 404, "message": "nope", "type": "invalid_request_error"}},
)
)

with pytest.raises(APIStatusError) as exc_info:
client.get("/foo", cast_to=httpx2.Response)

error = exc_info.value
assert isinstance(error.code, str)
assert error.code == "404"
assert error.type == "invalid_request_error"
# the raw value is preserved on the body for consumers that need it
assert error.body == {"code": 404, "message": "nope", "type": "invalid_request_error"}

@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
@pytest.mark.respx2(base_url=base_url)
def test_retrying_timeout_errors_doesnt_leak(self, respx2_mock: MockRouter, client: OpenAI) -> None:
Expand Down Expand Up @@ -2448,6 +2467,27 @@ async def test_invalid_retry_after_date_does_not_mask_status_error(

assert route.call_count == 1

@pytest.mark.respx2(base_url=base_url)
async def test_api_status_error_code_is_coerced_to_str(
self, respx2_mock: MockRouter, async_client: AsyncOpenAI
) -> None:
respx2_mock.get("/foo").mock(
return_value=httpx2.Response(
400,
json={"error": {"code": 404, "message": "nope", "type": "invalid_request_error"}},
)
)

with pytest.raises(APIStatusError) as exc_info:
await async_client.get("/foo", cast_to=httpx2.Response)

error = exc_info.value
assert isinstance(error.code, str)
assert error.code == "404"
assert error.type == "invalid_request_error"
# the raw value is preserved on the body for consumers that need it
assert error.body == {"code": 404, "message": "nope", "type": "invalid_request_error"}

@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
@pytest.mark.respx2(base_url=base_url)
async def test_retrying_timeout_errors_doesnt_leak(
Expand Down