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
5 changes: 5 additions & 0 deletions src/openai/lib/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
from ._tools import pydantic_function_tool as pydantic_function_tool
from ._parsing import ResponseFormatT as ResponseFormatT
from ._responses import (
RESPONSE_ERROR_CODE_TO_EXCEPTION as RESPONSE_ERROR_CODE_TO_EXCEPTION,
INCOMPLETE_DETAILS_REASON_TO_EXCEPTION as INCOMPLETE_DETAILS_REASON_TO_EXCEPTION,
exception_for_background_failure as exception_for_background_failure,
Comment thread
alextemn marked this conversation as resolved.
)
137 changes: 137 additions & 0 deletions src/openai/lib/_responses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
from __future__ import annotations

from typing import Dict, Type, Optional, NamedTuple

from .._exceptions import (
NotFoundError,
APIStatusError,
RateLimitError,
BadRequestError,
InternalServerError,
PermissionDeniedError,
)
from .._legacy_response import LegacyAPIResponse
from ..types.responses.response import Response

__all__ = [
"RESPONSE_ERROR_CODE_TO_EXCEPTION",
"INCOMPLETE_DETAILS_REASON_TO_EXCEPTION",
"exception_for_background_failure",
]


class _ErrorMapping(NamedTuple):
"""One entry in a background-failure mapping table.

Bundles the SDK exception class (``None`` means "no exception should be
raised for this code/reason") together with ``retryable``, so the two
facts can't drift apart.

``retryable`` describes the *original* background run, i.e. whether
submitting a fresh `create(..., background=True)` call is worth trying.
It does NOT mean calling `retrieve()` again on this same response id will
help -- a response that has reached a terminal "failed"/"incomplete"
status is immutable, so polling it again always returns the identical
failure.
"""

exception_class: Optional[Type[APIStatusError]]
retryable: bool


# `ResponseError.code` -> SDK exception. This is the entire signal a failed
# background run gives us (see `ResponseError` in
# `openai/types/responses/response_error.py`) -- there's no HTTP status to
# dispatch on, since the poll that surfaces this returns 200 OK.
#
# Unmapped codes (a value OpenAI ships before this table is updated) fall back
# to InternalServerError(retryable=True) in `exception_for_background_failure`
# -- an explicit "failed" status must never look like success.
RESPONSE_ERROR_CODE_TO_EXCEPTION: Dict[str, _ErrorMapping] = {
"server_error": _ErrorMapping(InternalServerError, True),
"rate_limit_exceeded": _ErrorMapping(RateLimitError, True),
"vector_store_timeout": _ErrorMapping(InternalServerError, True),
"data_residency_mismatch": _ErrorMapping(PermissionDeniedError, False),
Comment thread
alextemn marked this conversation as resolved.
"invalid_prompt": _ErrorMapping(BadRequestError, False),
"bio_policy": _ErrorMapping(BadRequestError, False),
"invalid_image": _ErrorMapping(BadRequestError, False),
"invalid_image_format": _ErrorMapping(BadRequestError, False),
"invalid_base64_image": _ErrorMapping(BadRequestError, False),
"invalid_image_url": _ErrorMapping(BadRequestError, False),
"image_too_large": _ErrorMapping(BadRequestError, False),
"image_too_small": _ErrorMapping(BadRequestError, False),
"image_parse_error": _ErrorMapping(BadRequestError, False),
"image_content_policy_violation": _ErrorMapping(BadRequestError, False),
"invalid_image_mode": _ErrorMapping(BadRequestError, False),
"image_file_too_large": _ErrorMapping(BadRequestError, False),
"unsupported_image_media_type": _ErrorMapping(BadRequestError, False),
"empty_image_file": _ErrorMapping(BadRequestError, False),
"failed_to_download_image": _ErrorMapping(BadRequestError, False),
"image_file_not_found": _ErrorMapping(NotFoundError, False),
}

# `IncompleteDetails.reason` -> SDK exception. Separate table from the one
# above: `status="incomplete"` is not automatically a failure (a run that hit
# `max_output_tokens` produced a structurally valid, complete-so-far result),
# so unlike `RESPONSE_ERROR_CODE_TO_EXCEPTION` there is no "unmapped ->
# fall back to an exception" behavior here -- an unrecognized or absent
# reason simply means we return None (don't raise).
INCOMPLETE_DETAILS_REASON_TO_EXCEPTION: Dict[str, _ErrorMapping] = {
"max_output_tokens": _ErrorMapping(None, False),
"content_filter": _ErrorMapping(BadRequestError, False),
}

_UNMAPPED_FAILURE = _ErrorMapping(InternalServerError, True)


def exception_for_background_failure(
raw_response: LegacyAPIResponse[Response],
response: Response,
) -> Optional[APIStatusError]:
"""Return the exception a failed/incomplete background Response should raise, or None.

Background runs finish with a normal HTTP 200 even when the run itself
failed, so the SDK's usual status-code-based error handling never
triggers. `Responses.retrieve()` calls this once a run is no longer
queued/in-progress, to translate `error.code` (or, for status=
"incomplete", `incomplete_details.reason`) into the same exception class
a synchronous request with an equivalent failure would have raised.

The raised exception also carries a best-effort `.retryable` attribute
(not declared on the base exception classes -- read it with
`getattr(exc, "retryable", False)`). `RESPONSE_ERROR_CODE_TO_EXCEPTION`
and `INCOMPLETE_DETAILS_REASON_TO_EXCEPTION` are the documented source of
truth for this flag. `retryable` means "submitting a new background run
is worth trying" -- calling `retrieve()` again on this same response id
will never help, since a terminal "failed"/"incomplete" response is
immutable.

Returns None if the response didn't fail, or "failed"/"incomplete" but
without enough information to classify (e.g. no `error`/
`incomplete_details` populated).
"""
mapping: Optional[_ErrorMapping] = None
message = "Background response run did not complete successfully."

if response.status == "failed":
if response.error is None:
return None
message = response.error.message
mapping = RESPONSE_ERROR_CODE_TO_EXCEPTION.get(response.error.code, _UNMAPPED_FAILURE)
elif response.status == "incomplete":
reason = response.incomplete_details.reason if response.incomplete_details else None
if reason is None:
return None
mapping = INCOMPLETE_DETAILS_REASON_TO_EXCEPTION.get(reason)
message = f"Background response run was incomplete: {reason}"
else:
return None

if mapping is None or mapping.exception_class is None:
return None

Comment thread
alextemn marked this conversation as resolved.
body = {"code": getattr(response.error, "code", None), "message": message}
exc = mapping.exception_class(message, response=raw_response.http_response, body=body)
# retryable == "resubmit as a new background run", not "call retrieve() again"
exc.retryable = mapping.retryable # type: ignore[attr-defined]
return exc
156 changes: 118 additions & 38 deletions src/openai/resources/responses/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
InputItemsWithStreamingResponse,
AsyncInputItemsWithStreamingResponse,
)
from ..._constants import RAW_RESPONSE_HEADER
from ..._streaming import Stream, AsyncStream
from ...lib._tools import PydanticFunctionTool, ResponsesPydanticFunctionTool
from .input_tokens import (
Expand All @@ -58,6 +59,7 @@
from ..._send_queue import SendQueue
from ..._base_client import _merge_mappings, make_request_options
from ..._event_handler import EventHandlerRegistry
from ...lib._responses import exception_for_background_failure
from ...types.responses import (
response_create_params,
response_compact_params,
Expand Down Expand Up @@ -1624,28 +1626,67 @@ def retrieve(
) -> Response | Stream[ResponseStreamEvent]:
if not response_id:
raise ValueError(f"Expected a non-empty value for `response_id` but received {response_id!r}")
return self._get(
path_template("/responses/{response_id}", response_id=response_id),
options=make_request_options(
extra_headers=extra_headers,
extra_query=extra_query,
extra_body=extra_body,
timeout=timeout,
query=maybe_transform(
{
"include": include,
"include_obfuscation": include_obfuscation,
"starting_after": starting_after,
"stream": stream,
},
response_retrieve_params.ResponseRetrieveParams,

path = path_template("/responses/{response_id}", response_id=response_id)
query = maybe_transform(
{
"include": include,
"include_obfuscation": include_obfuscation,
"starting_after": starting_after,
"stream": stream,
},
response_retrieve_params.ResponseRetrieveParams,
)

if stream or (extra_headers is not None and RAW_RESPONSE_HEADER in extra_headers):
# Preserve the original behavior untouched for: (a) real SSE streaming
# requests, and (b) calls already going through one of the SDK's own
# raw/streamed-response wrappers (with_raw_response,
# with_streaming_response) -- those inject RAW_RESPONSE_HEADER
# themselves and need `_get` to hand back their own wrapper type, not
# our parsed-and-possibly-raising Response below.
return self._get(
path,
options=make_request_options(
extra_headers=extra_headers,
extra_query=extra_query,
extra_body=extra_body,
timeout=timeout,
query=query,
security={"bearer_auth": True},
),
security={"bearer_auth": True},
cast_to=Response,
stream=stream or False,
stream_cls=Stream[ResponseStreamEvent],
)

# Ask for the raw HTTP response (same request, no extra round trip) so a
# failed/incomplete background run can be turned into a typed exception
# below -- such a run still comes back as a plain HTTP 200.
raw_response = cast(
"_legacy_response.LegacyAPIResponse[Response]",
self._get(
path,
options=make_request_options(
extra_headers={RAW_RESPONSE_HEADER: "true", **(extra_headers or {})},
extra_query=extra_query,
extra_body=extra_body,
timeout=timeout,
query=query,
security={"bearer_auth": True},
),
cast_to=Response,
stream=False,
),
cast_to=Response,
stream=stream or False,
stream_cls=Stream[ResponseStreamEvent],
)
response = raw_response.parse()

if response.status not in ("queued", "in_progress", "completed"):
exc = exception_for_background_failure(raw_response, response)
if exc is not None:
raise exc

return response

def delete(
self,
Expand Down Expand Up @@ -3491,28 +3532,67 @@ async def retrieve(
) -> Response | AsyncStream[ResponseStreamEvent]:
if not response_id:
raise ValueError(f"Expected a non-empty value for `response_id` but received {response_id!r}")
return await self._get(
path_template("/responses/{response_id}", response_id=response_id),
options=make_request_options(
extra_headers=extra_headers,
extra_query=extra_query,
extra_body=extra_body,
timeout=timeout,
query=await async_maybe_transform(
{
"include": include,
"include_obfuscation": include_obfuscation,
"starting_after": starting_after,
"stream": stream,
},
response_retrieve_params.ResponseRetrieveParams,

path = path_template("/responses/{response_id}", response_id=response_id)
query = await async_maybe_transform(
{
"include": include,
"include_obfuscation": include_obfuscation,
"starting_after": starting_after,
"stream": stream,
},
response_retrieve_params.ResponseRetrieveParams,
)

if stream or (extra_headers is not None and RAW_RESPONSE_HEADER in extra_headers):
# Preserve the original behavior untouched for: (a) real SSE streaming
# requests, and (b) calls already going through one of the SDK's own
# raw/streamed-response wrappers (with_raw_response,
# with_streaming_response) -- those inject RAW_RESPONSE_HEADER
# themselves and need `_get` to hand back their own wrapper type, not
# our parsed-and-possibly-raising Response below.
return await self._get(
path,
options=make_request_options(
extra_headers=extra_headers,
extra_query=extra_query,
extra_body=extra_body,
timeout=timeout,
query=query,
security={"bearer_auth": True},
),
security={"bearer_auth": True},
cast_to=Response,
stream=stream or False,
stream_cls=AsyncStream[ResponseStreamEvent],
)

# Ask for the raw HTTP response (same request, no extra round trip) so a
# failed/incomplete background run can be turned into a typed exception
# below -- such a run still comes back as a plain HTTP 200.
raw_response = cast(
"_legacy_response.LegacyAPIResponse[Response]",
await self._get(
path,
options=make_request_options(
extra_headers={RAW_RESPONSE_HEADER: "true", **(extra_headers or {})},
extra_query=extra_query,
extra_body=extra_body,
timeout=timeout,
query=query,
security={"bearer_auth": True},
),
cast_to=Response,
stream=False,
),
cast_to=Response,
stream=stream or False,
stream_cls=AsyncStream[ResponseStreamEvent],
)
response = raw_response.parse()

if response.status not in ("queued", "in_progress", "completed"):
exc = exception_for_background_failure(raw_response, response)
if exc is not None:
raise exc

return response

async def delete(
self,
Expand Down
Loading