From 741aad756293db5f264bb39d968dac11ea6c93fc Mon Sep 17 00:00:00 2001 From: okxint Date: Wed, 2 Sep 2026 12:11:52 +0530 Subject: [PATCH 1/2] fix(exceptions): coerce APIError.code to str to match Optional[str] annotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit APIError.code is annotated Optional[str], but the OpenAI API (and compatible gateways) can return a JSON number in the error.code field. construct_type returns non-matching values unchanged, and the cast(Any,…) suppressed type-checker warnings, so integer codes silently escaped as int at runtime — crashing downstream code that trusted the annotation (e.g. exc.code.strip() raises AttributeError). Fix: replace the construct_type call with an explicit coercion: raw_code = body.get("code") self.code = str(raw_code) if raw_code is not None else None This keeps the annotation correct (Optional[str]), is backward-compatible for callers that already receive string codes, and converts integer codes (404, 429, etc.) to their string representations. Fixes #3531 Co-Authored-By: Claude Sonnet 4.6 --- src/openai/_exceptions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/openai/_exceptions.py b/src/openai/_exceptions.py index 7a30e4a336..5f5f911faf 100644 --- a/src/openai/_exceptions.py +++ b/src/openai/_exceptions.py @@ -69,7 +69,8 @@ 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"))) + raw_code = body.get("code") + self.code = str(raw_code) if raw_code is not None else None 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"))) else: From 2d9e0e6fb6b8ef13da6cacda094fa553b3ce68a3 Mon Sep 17 00:00:00 2001 From: okxint Date: Thu, 3 Sep 2026 11:52:22 +0530 Subject: [PATCH 2/2] =?UTF-8?q?test:=20add=20regression=20test=20for=20API?= =?UTF-8?q?Error.code=20int=E2=86=92str=20coercion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_client.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_client.py b/tests/test_client.py index d82c39e616..9edd41c808 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -3038,3 +3038,32 @@ def provider() -> str: assert len(calls) == 2 assert provider_call_count == 1 + + +def test_api_error_code_coercion() -> None: + """Regression test: APIError.code must always be Optional[str], never int. + + The API can return a numeric code (e.g. 400) in the error body. Before the + fix, construct_type returned the raw int when the target type didn't match, + and cast(Any, ...) hid that from the type checker — so .code was silently int + at runtime, breaking any caller using str methods like .strip() or ==. + """ + from openai._exceptions import APIError + + request = httpx2.Request("GET", "http://localhost") + + # Integer code must be coerced to str. + err = APIError("msg", request, body={"code": 400, "param": None, "type": "t"}) + assert err.code == "400" + assert isinstance(err.code, str) + + # String code must pass through unchanged. + err = APIError("msg", request, body={"code": "invalid_api_key", "param": None, "type": "t"}) + assert err.code == "invalid_api_key" + + # Absent / null code must yield None. + err = APIError("msg", request, body={"code": None, "param": None, "type": "t"}) + assert err.code is None + + err = APIError("msg", request, body={"param": None, "type": "t"}) + assert err.code is None