Skip to content

Commit 0598bab

Browse files
fix(retry): retry on httpx.TimeoutException with HttpRetryOptions (#2345)
The retry predicate in `retry_args` only matched `errors.APIError` subclasses, so lower-level httpx transport exceptions (`httpx.TimeoutException`, `httpx.ConnectError`, and their subclasses such as `ReadTimeout` / `ConnectTimeout`) were silently dropped and never retried, even when `HttpRetryOptions` was explicitly configured. Long-running LLM inference calls regularly hit transient transport errors from load balancers, NAT gateways, and idle TCP timeouts. Vertex AI's retry strategy guidance lists socket timeouts and TCP disconnects as retryable, so honoring those here matches user expectations. Fixes #2337. Co-authored-by: Amy Wu <[email protected]>
1 parent f8a2e7e commit 0598bab

2 files changed

Lines changed: 19 additions & 1 deletion

File tree

‎google/genai/_api_client.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -524,7 +524,8 @@ def retry_args(options: Optional[HttpRetryOptions]) -> _common.StringDict:
524524
stop = tenacity.stop_after_attempt(options.attempts or _RETRY_ATTEMPTS)
525525
retriable_codes = options.http_status_codes or _RETRY_HTTP_STATUS_CODES
526526
retry = tenacity.retry_if_exception(
527-
lambda e: isinstance(e, errors.APIError) and e.code in retriable_codes,
527+
lambda e: (isinstance(e, errors.APIError) and e.code in retriable_codes)
528+
or isinstance(e, (httpx.TimeoutException, httpx.ConnectError)),
528529
)
529530
wait = tenacity.wait_exponential_jitter(
530531
initial=options.initial_delay or _RETRY_INITIAL_DELAY,

‎google/genai/tests/client/test_retries.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,23 @@ def test_retry_args_enabled_with_custom_values_are_not_overridden():
187187
assert not retry.predicate(e)
188188

189189

190+
def test_retry_args_retries_httpx_transport_errors():
191+
# httpx transport errors (timeouts, connect errors) bypass APIError but are
192+
# transient infrastructure failures, so the predicate must still retry them
193+
# when HttpRetryOptions is configured. See issue #2337.
194+
args = api_client.retry_args(types.HttpRetryOptions())
195+
retry = args['retry']
196+
197+
assert retry.predicate(httpx.TimeoutException('stalled'))
198+
assert retry.predicate(httpx.ReadTimeout('read stalled'))
199+
assert retry.predicate(httpx.ConnectTimeout('connect stalled'))
200+
assert retry.predicate(httpx.ConnectError('connect refused'))
201+
202+
# Unrelated transport errors are not retried.
203+
assert not retry.predicate(httpx.InvalidURL('bad url'))
204+
assert not retry.predicate(ValueError('not a transport error'))
205+
206+
190207
def _patch_auth_default():
191208
return mock.patch(
192209
'google.auth.default',

0 commit comments

Comments
 (0)