The request retry loop in _base_client.py caught bare `Exception`,
which meant any error raised while a request was in flight -
including ones with nothing to do with the HTTP request itself -
was treated as a retryable connection error and eventually wrapped
in APIConnectionError.
In particular, running the client inside a Celery task with a soft
time limit causes Celery's SoftTimeLimitExceeded (a plain Exception
subclass) to be swallowed by this handler and retried instead of
propagating, so task cleanup/shutdown logic relying on it never runs
(openai#2737).
Add request_exceptions() alongside the existing timeout_exceptions()/
status_exceptions() helpers in _httpx2.py, and use it to narrow the
retry-on-exception branch to httpx2.RequestError (and the legacy
httpx.RequestError, for users who inject a legacy AsyncClient) -
covering connection failures, protocol errors, and other genuine
transport errors, while letting unrelated exceptions propagate
immediately and unmodified.
Fixes openai#2737
Summary
The request retry loop in _base_client.py catches bare Exception and treats it exactly like a connection failure: it retries (if retries remain) and otherwise wraps it in APIConnectionError. That means any exception raised while a request is in flight — even one that has nothing to do with the HTTP request — gets silently swallowed and retried.
Concretely, this breaks graceful shutdown for callers that run the client inside a Celery task with a soft time limit: Celery's SoftTimeLimitExceeded is a plain Exception subclass, so it gets caught here, retried, and the task's cleanup/shutdown logic that depends on that exception propagating never runs.
Fixes #2737.
Root cause
Both the sync (SyncAPIClient._request) and async (AsyncAPIClient._request) retry loops had this issue.
Fix
Unrelated exceptions (e.g. SoftTimeLimitExceeded, or any other exception raised by code running underneath the transport) now propagate immediately, unmodified, and without being retried or wrapped in APIConnectionError.
Tests
All of tests/test_client.py passes locally (198 passed, 2 pre-existing/unrelated failures in test_proxy_environment_variables that reproduce identically on main without this change, 2 skipped). ruff check, ruff format --check, and mypy are clean on the changed files.
Notes for reviewers