Skip to content
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,38 @@ Error codes are as follows:
| >=500 | `InternalServerError` |
| N/A | `APIConnectionError` |

### Responses that fail without an HTTP error

A background Response reports its own outcome inside the body of the poll rather than in the HTTP status. A run
that failed still comes back as `200 OK` with `status="failed"`, so none of the errors above are raised and
`client.responses.retrieve()` returns the resource normally.

That is deliberate — `retrieve()` returns the `Response` for every status so you can inspect `status`, `error`,
partial `output` and `metadata`. To turn a non-completed run into an exception, pass it to `raise_for_status()`:

```python
from openai import OpenAI, ResponseFailedError
from openai.lib import raise_for_status

client = OpenAI()

response = client.responses.retrieve("resp_123")

try:
raise_for_status(response)
except ResponseFailedError as exc:
print(exc.code) # "server_error"
print(exc.retryable) # whether a new `create()` call is worth trying
print(exc.response.output) # the parsed Response is still available
```

`status="failed"` raises `ResponseFailedError`, and `status="incomplete"` raises `ResponseIncompleteError` only
when you pass `raise_on_incomplete=True` — a run truncated at `max_output_tokens` still has usable output.
`cancelled` never raises. Both exceptions inherit from `ResponseNotCompletedError` and are plain `OpenAIError`
subclasses with no `status_code`, since the poll itself really did return `200 OK`.

See [helpers.md](helpers.md#response-status-helpers) for the full list of statuses, fields and retry semantics.

## Request IDs

> For more information on debugging requests, see [these docs](https://platform.openai.com/docs/api-reference/debugging-requests)
Expand Down
98 changes: 98 additions & 0 deletions helpers.md
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,104 @@ client.vector_stores.file_batches.upload_and_poll(...)
client.videos.create_and_poll(...)
```

# Response Status Helpers

A background Response reports its own outcome inside the body of the poll, not in the HTTP status. A run that
failed still comes back as `200 OK` with `status="failed"`, so the SDK's normal 4xx/5xx error handling never
fires and the failure is easy to miss.

`retrieve()` deliberately keeps returning the resource for every status, so you can always inspect `status`,
`error`, partial `output` and `metadata`. When you would rather have an exception, pass the response to
`raise_for_status()`:

```py
from openai import OpenAI, ResponseFailedError
from openai.lib import raise_for_status

client = OpenAI()

response = client.responses.retrieve("resp_123")

try:
raise_for_status(response)
except ResponseFailedError as exc:
print(exc.code) # "server_error"
print(exc.retryable) # True
print(exc.request_id) # "req_123"
print(exc.response.output) # the parsed Response is still fully available
```

## What raises

| `response.status` | Behavior |
| ----------------------- | --------------------------------------------------------------------------------- |
| `completed` | returns |
| `queued`, `in_progress` | returns — not a terminal state |
| `cancelled` | returns — cancellation is caller-initiated, not a failure |
| `failed` | raises `ResponseFailedError` |
| `incomplete` | returns by default; raises `ResponseIncompleteError` with `raise_on_incomplete=True` |

`incomplete` is opt-in because the usual reason is `max_output_tokens`, which still leaves usable content on
`response.output`. Opt in when a truncated run should be treated as an error:

```py
raise_for_status(response, raise_on_incomplete=True)
```

## Exception types

Both exceptions inherit from `ResponseNotCompletedError`, so you can catch either kind in one clause:

```py
from openai import ResponseNotCompletedError

try:
raise_for_status(response, raise_on_incomplete=True)
except ResponseNotCompletedError as exc:
print(exc.response.status) # "failed" or "incomplete"
```

| Attribute | Type | Description |
| ------------ | ----------------- | -------------------------------------------------------------------------- |
| `response` | `Response` | The parsed resource, including any partial `output` |
| `request_id` | `str \| None` | The `x-request-id` of the poll, for reporting issues to OpenAI |
| `retryable` | `bool` | Whether submitting a **new** `create()` call is worth trying |
| `code` | `str \| None` | `ResponseFailedError` only — `response.error.code` |
| `reason` | `str \| None` | `ResponseIncompleteError` only — `response.incomplete_details.reason` |

These are plain `OpenAIError` subclasses, **not** `APIStatusError` subclasses, and they carry no `status_code`.
The poll that surfaced the failure really did return `200 OK`, so there is no HTTP status to report; a
`status_code` of `400` or `429` here would be a fabrication that breaks retry and telemetry logic keyed on it.

`retryable` is `True` only for transient failures — `server_error`, `rate_limit_exceeded` and
`vector_store_timeout`. Any other code, including one this version of the SDK does not recognize, is reported as
non-retryable so that a future validation, policy or quota code is never mistaken for a transient one.

> [!NOTE]
> `retryable` never means re-`retrieve()`ing the same id. A terminal response is immutable, so polling it again
> returns the identical failure. It means the original operation is worth submitting again with `create()`.

`ResponseFailedError` is also raised when the API reports `status="failed"` without an `error` object; `code` is
`None` in that case, and the failure still surfaces rather than passing silently.

## Async and raw responses

`raise_for_status()` is a plain function over a parsed `Response`, so it behaves identically on the async client
and across every retrieval wrapper:

```py
response = await client.responses.retrieve("resp_123")
raise_for_status(response)

# `.with_raw_response` and `.with_streaming_response` return the same resource
raw = client.responses.with_raw_response.retrieve("resp_123")
raise_for_status(raw.parse())

# with `stream=True`, classify the response carried by the terminal event
for event in client.responses.retrieve("resp_123", stream=True):
if event.type == "response.failed":
raise_for_status(event.response)
```
# Beta Agents turn results

Both streamed session creation and the one-turn session helper can collect the final
Expand Down
6 changes: 6 additions & 0 deletions src/openai/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,13 @@
APIConnectionError,
AuthenticationError,
InternalServerError,
ResponseFailedError,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Generate the package-root exception exports

src/openai/__init__.py is Castiron-owned, but this commit hand-edits its imports and __all__ without updating the generation configuration or inventory (the manifest still records the parent checksum). The next routine schema regeneration can therefore remove these required package-root exports and break the documented from openai import ResponseFailedError; add the exports through the generator configuration/compiler and regenerate the file instead.

AGENTS.md reference: AGENTS.md:L5-L7

Useful? React with 👍 / 👎.

PermissionDeniedError,
LengthFinishReasonError as LengthFinishReasonError,
ResponseIncompleteError,
WebSocketQueueFullError,
UnprocessableEntityError,
ResponseNotCompletedError,
APIResponseValidationError,
InvalidWebhookSignatureError as InvalidWebhookSignatureError,
ContentFilterFinishReasonError as ContentFilterFinishReasonError,
Expand Down Expand Up @@ -76,6 +79,9 @@
"InternalServerError",
"LengthFinishReasonError",
"ContentFilterFinishReasonError",
"ResponseNotCompletedError",
"ResponseFailedError",
"ResponseIncompleteError",
"InvalidWebhookSignatureError",
"Timeout",
"RequestOptions",
Expand Down
54 changes: 54 additions & 0 deletions src/openai/_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

if TYPE_CHECKING:
from .types.chat import ChatCompletion
from .types.responses.response import Response

__all__ = [
"BadRequestError",
Expand All @@ -24,6 +25,9 @@
"InternalServerError",
"LengthFinishReasonError",
"ContentFilterFinishReasonError",
"ResponseNotCompletedError",
"ResponseFailedError",
"ResponseIncompleteError",
"InvalidWebhookSignatureError",
"SubjectTokenProviderError",
"WebSocketConnectionClosedError",
Expand Down Expand Up @@ -193,6 +197,56 @@ def __init__(self, *, completion: ChatCompletion | None = None) -> None:
self.completion = completion


class ResponseNotCompletedError(OpenAIError):
"""A Response reached a final state other than `completed`.

Carries no HTTP status code: the request that surfaced the failure succeeded.
"""

response: Response

retryable: bool
"""Whether submitting a new `create()` call is worth trying.

Never means re-`retrieve()`ing this id, which is immutable once terminal.
"""

def __init__(self, message: str, *, response: Response, retryable: bool) -> None:
super().__init__(message)
self.response = response
self.retryable = retryable

@property
def request_id(self) -> str | None:
return getattr(self.response, "_request_id", None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve request IDs for streamed terminal responses

When raise_for_status(event.response) is used on a retrieve(..., stream=True) terminal event whose HTTP response includes x-request-id, Stream.__stream__ and AsyncStream.__stream__ construct the event through _process_response_data and never run add_request_id (that happens only in APIResponse.parse() for top-level parsed models). The nested Response therefore has no _request_id, so this getter returns None and the newly advertised exception field loses the poll ID needed for diagnostics; propagate the stream header to the event response or otherwise carry it into the exception.

Useful? React with 👍 / 👎.



class ResponseFailedError(ResponseNotCompletedError):
"""A Response finished with `status="failed"`."""

code: Optional[str]
"""`response.error.code`, or `None` when the API reported no error object."""

def __init__(self, message: str, *, response: Response, code: Optional[str], retryable: bool) -> None:
super().__init__(message, response=response, retryable=retryable)
self.code = code


class ResponseIncompleteError(ResponseNotCompletedError):
"""A Response finished with `status="incomplete"`.

Not necessarily a failure: a run that stopped at `max_output_tokens` still
produced output on `.response.output`.
"""

reason: Optional[str]
"""`response.incomplete_details.reason`, or `None` when the API reported no reason."""

def __init__(self, message: str, *, response: Response, reason: Optional[str], retryable: bool) -> None:
super().__init__(message, response=response, retryable=retryable)
self.reason = reason


class InvalidWebhookSignatureError(ValueError):
"""Raised when a webhook signature is invalid, meaning the computed signature does not match the expected signature."""

Expand Down
1 change: 1 addition & 0 deletions src/openai/lib/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
pydantic_responses_function_tool as pydantic_responses_function_tool,
)
from ._parsing import ResponseFormatT as ResponseFormatT, type_to_response_format_param as type_to_response_format_param
from ._responses import raise_for_status as raise_for_status
46 changes: 46 additions & 0 deletions src/openai/lib/_responses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from __future__ import annotations

from .._exceptions import ResponseFailedError, ResponseIncompleteError
from ..types.responses.response import Response

__all__ = ["raise_for_status"]

_RETRYABLE_ERROR_CODES = frozenset({"server_error", "rate_limit_exceeded", "vector_store_timeout"})


def raise_for_status(response: Response, *, raise_on_incomplete: bool = False) -> None:
"""Raise if `response` reached a final state other than `completed`.

A background run reports its own failure inside a `200 OK` poll, so `retrieve()`
returns it instead of raising. Call this to opt into an exception. `"cancelled"`
is caller-initiated and does not raise.

`status="incomplete"` only raises when `raise_on_incomplete` is set: the usual
reason is `max_output_tokens`, which still leaves usable output on
`response.output`.
"""
if response.status == "failed":
error = response.error
if error is None:
raise ResponseFailedError(
"Response failed without an error code.",
response=response,
code=None,
retryable=False,
)

raise ResponseFailedError(
error.message,
response=response,
code=error.code,
retryable=error.code in _RETRYABLE_ERROR_CODES,
)

if response.status == "incomplete" and raise_on_incomplete:
reason = response.incomplete_details.reason if response.incomplete_details else None
raise ResponseIncompleteError(
f"Response was incomplete: {reason or 'no reason reported'}.",
response=response,
reason=reason,
retryable=False,
)
Loading