Skip to content

APIStatusError.code is typed Optional[str] but can be an int at runtime #3531

Description

@emiliyanpavlov

Confirm this is an issue with the Python library and not an underlying OpenAI API

  • This is an issue with the Python library

Describe the bug

APIStatusError.code is annotated Optional[str], but at runtime it can hold an int (or any other JSON type the server puts in error.code).

In openai/_exceptions.py the field is built with the lenient deserializer and an explicit cast(Any, ...):

self.code = cast(Any, construct_type(type_=Optional[str], value=body.get("code")))

construct_type returns the value unchanged when it doesn't match the target type — it neither coerces nor rejects — and the cast(Any, ...) suppresses the type checker, so nothing ever enforces the Optional[str] annotation. When an error body contains a JSON number ({"error": {"code": 404, ...}}), exc.code is an int.

This bit us in production: downstream code that trusted the annotation called exc.code.strip() and crashed with AttributeError: 'int' object has no attribute 'strip'. We observed the integer-code error body coming from api.openai.com itself (a one-off during a burst of concurrent chat completions), and OpenAI-compatible gateways/servers return integer codes routinely.

Suggested fix: either coerce non-None values to str when constructing the exception, or widen the annotation so type checkers tell consumers the truth.

To Reproduce

The value flows through construct_type unvalidated:

from typing import Optional
from openai._models import construct_type

value = construct_type(type_=Optional[str], value=404)
print(type(value))  # <class 'int'> — despite the Optional[str] target

End to end: point the client at any server whose error body contains "code": 404 (integer), catch the raised APIStatusError, and inspect exc.code.

Code snippets

import openai

client = openai.OpenAI(api_key="test", base_url="http://localhost:9999/v1")
# server responds 404 with body: {"error": {"message": "nope", "type": "invalid_request_error", "code": 404}}
try:
    client.chat.completions.create(model="gpt-4.1", messages=[{"role": "user", "content": "hi"}])
except openai.APIStatusError as exc:
    reveal_type(exc.code)  # type checker: str | None
    print(type(exc.code))  # runtime: <class 'int'>

OS

macOS / Linux

Python version

Python 3.14.6

Library version

openai v2.46.0

Activity

  1. IgorGanapolsky commented on Jul 24, 2026

    @IgorGanapolsky

    Type-contract reliability note (fail-closed consumers):

    If APIStatusError.code is annotated Optional[str] but can be an int (or other JSON scalar) at runtime, any code that does code.startswith(...) / strict str matching will throw after the HTTP failure already happened — turning a classified API error into an unhandled local exception.

    Defensive pattern until the type is widened/normalized:

    raw = err.code
    code = str(raw) if raw is not None else None
    # branch on code as str; never assume .startswith exists on the raw field

    Also worth asserting in tests with a synthetic body {"error": {"code": 429, "message": "..."}} so the constructor path cannot regress to "typed str, runtime int" without CI noise.

    This is the same class of bug as "expected vs actual" on error envelopes: the wire shape is the source of truth; the annotation should not invent a narrower type than the server emits.

  2. nightcityblade commented on Jul 26, 2026

    @nightcityblade
    Contributor

    Hi, I'd like to work on this. I'll submit a PR shortly.

  3. added a commit that references this issue on Aug 10, 2026
    29337ae
  4. added a commit that references this issue on Sep 14, 2026
    233d955
  5. marcuswood-oai commented on Sep 14, 2026

    @marcuswood-oai
    Contributor

    Thanks for reporting this and explaining how it broke your error handling. The SDK’s loose deserializer could leave .code as an integer despite its str | None annotation. #3532 is now merged: non-null codes become strings, while .body retains the original value. This issue is closed because the SDK fix is on main.

    The fix is not yet in the latest release, v3.13.0. Until a release includes it, use code = str(exc.code) if exc.code is not None else None before string operations. After upgrading to a release with the fix, change numeric comparisons such as exc.code == 404 to exc.code == "404", or read the original value from exc.body if needed.

  6. added a commit that references this issue on Sep 14, 2026
  7. added a commit that references this issue on Sep 21, 2026
    7a59add
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions