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: 3 additions & 2 deletions src/openai/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
from ._compat import PYDANTIC_V1, model_copy
from ._httpx2 import (
status_exceptions,
request_exceptions,
timeout_exceptions,
http_response_types,
normalize_httpx_url,
Expand Down Expand Up @@ -1095,7 +1096,7 @@ def request(
except OpenAIError as err:
# Propagate OpenAIErrors as-is, without retrying or wrapping in APIConnectionError
raise err
except Exception as err:
except request_exceptions() as err:
log.debug("Encountered exception: %s", type(err).__name__)

if remaining_retries > 0:
Expand Down Expand Up @@ -1718,7 +1719,7 @@ async def request(
except OpenAIError as err:
# Propagate OpenAIErrors as-is, without retrying or wrapping in APIConnectionError
raise err
except Exception as err:
except request_exceptions() as err:
log.debug("Encountered exception: %s", type(err).__name__)

if remaining_retries > 0:
Expand Down
11 changes: 11 additions & 0 deletions src/openai/_httpx2.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class _LegacyHttpxModule(Protocol):
Timeout: type[httpx2.Timeout]
Limits: type[httpx2.Limits]
TimeoutException: type[httpx2.TimeoutException]
RequestError: type[httpx2.RequestError]
HTTPStatusError: type[httpx2.HTTPStatusError]
StreamConsumed: type[httpx2.StreamConsumed]
RequestNotRead: type[httpx2.RequestNotRead]
Expand Down Expand Up @@ -99,6 +100,16 @@ def timeout_exceptions() -> tuple[type[httpx2.TimeoutException], ...]:
return (httpx2.TimeoutException,) if module is None else (httpx2.TimeoutException, module.TimeoutException)


def request_exceptions() -> tuple[type[httpx2.RequestError], ...]:
"""Exceptions raised by the transport for a single request: connection failures, protocol
errors, timeouts, and the like. Deliberately narrower than `Exception` so that unrelated
errors (e.g. a task-cancellation signal raised inside a custom transport) are not silently
retried and reported as an `APIConnectionError`.
"""
module = _loaded_legacy_httpx()
return (httpx2.RequestError,) if module is None else (httpx2.RequestError, module.RequestError)


def status_exceptions() -> tuple[type[httpx2.HTTPStatusError], ...]:
module = _loaded_legacy_httpx()
return (httpx2.HTTPStatusError,) if module is None else (httpx2.HTTPStatusError, module.HTTPStatusError)
Expand Down
72 changes: 69 additions & 3 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from openai._utils import asyncify
from openai._models import BaseModel, FinalRequestOptions
from openai._streaming import Stream, AsyncStream
from openai._exceptions import APIStatusError, APITimeoutError, APIResponseValidationError
from openai._exceptions import APIStatusError, APITimeoutError, APIConnectionError, APIResponseValidationError
from openai._base_client import (
DEFAULT_TIMEOUT,
HTTPX_DEFAULT_TIMEOUT,
Expand Down Expand Up @@ -1208,7 +1208,7 @@ def retry_handler(_request: httpx2.Request) -> httpx2.Response:
if nb_retries < failures_before_success:
nb_retries += 1
if failure_mode == "exception":
raise RuntimeError("oops")
raise httpx2.ConnectError("oops")
return httpx2.Response(500)
return httpx2.Response(200)

Expand All @@ -1227,6 +1227,40 @@ def retry_handler(_request: httpx2.Request) -> httpx2.Response:
assert response.retries_taken == failures_before_success
assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success

@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
@pytest.mark.respx2(base_url=base_url)
def test_non_transport_exceptions_are_not_retried(self, client: OpenAI, respx2_mock: MockRouter) -> None:
# Exceptions that aren't raised by the transport layer (connection failures, timeouts,
# protocol errors, ...) must propagate immediately, unmodified, and without being
# retried. Previously a bare `except Exception` treated *any* error - including ones
# unrelated to the HTTP request, such as Celery's `SoftTimeLimitExceeded` - as a
# retryable connection error, which silently discarded the original exception and any
# cleanup logic relying on it.
client = client.with_options(max_retries=4)

nb_calls = 0

def raise_non_transport_error(_request: httpx2.Request) -> httpx2.Response:
nonlocal nb_calls
nb_calls += 1
raise RuntimeError("not a transport error")

respx2_mock.post("/chat/completions").mock(side_effect=raise_non_transport_error)

with pytest.raises(RuntimeError, match="not a transport error") as exc_info:
client.chat.completions.create(
messages=[
{
"content": "string",
"role": "developer",
}
],
model="gpt-5.4",
)

assert not isinstance(exc_info.value, APIConnectionError)
assert nb_calls == 1

@pytest.mark.parametrize("failures_before_success", [0, 2, 4])
@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
@pytest.mark.respx2(base_url=base_url)
Expand Down Expand Up @@ -2505,7 +2539,7 @@ def retry_handler(_request: httpx2.Request) -> httpx2.Response:
if nb_retries < failures_before_success:
nb_retries += 1
if failure_mode == "exception":
raise RuntimeError("oops")
raise httpx2.ConnectError("oops")
return httpx2.Response(500)
return httpx2.Response(200)

Expand All @@ -2524,6 +2558,38 @@ def retry_handler(_request: httpx2.Request) -> httpx2.Response:
assert response.retries_taken == failures_before_success
assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success

@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
@pytest.mark.respx2(base_url=base_url)
async def test_non_transport_exceptions_are_not_retried(
self, async_client: AsyncOpenAI, respx2_mock: MockRouter
) -> None:
# See the sync counterpart above for context: a non-transport exception must propagate
# immediately, unmodified, and without being retried.
client = async_client.with_options(max_retries=4)

nb_calls = 0

def raise_non_transport_error(_request: httpx2.Request) -> httpx2.Response:
nonlocal nb_calls
nb_calls += 1
raise RuntimeError("not a transport error")

respx2_mock.post("/chat/completions").mock(side_effect=raise_non_transport_error)

with pytest.raises(RuntimeError, match="not a transport error") as exc_info:
await client.chat.completions.create(
messages=[
{
"content": "string",
"role": "developer",
}
],
model="gpt-5.4",
)

assert not isinstance(exc_info.value, APIConnectionError)
assert nb_calls == 1

@pytest.mark.parametrize("failures_before_success", [0, 2, 4])
@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
@pytest.mark.respx2(base_url=base_url)
Expand Down