diff --git a/src/openai/_exceptions.py b/src/openai/_exceptions.py index 7a30e4a336..19fa5085f9 100644 --- a/src/openai/_exceptions.py +++ b/src/openai/_exceptions.py @@ -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: @@ -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 diff --git a/tests/test_client.py b/tests/test_client.py index d82c39e616..d93972fdaa 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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: @@ -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(