Skip to content

Commit 1eb0dbe

Browse files
committed
fix: honor RetryInfo.retryDelay for 429 RESOURCE_EXHAUSTED
1 parent 15666c0 commit 1eb0dbe

2 files changed

Lines changed: 155 additions & 3 deletions

File tree

google/genai/_api_client.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,44 @@ def _load_json_from_response(cls, response: Any) -> Any:
474474
)
475475

476476

477+
def _extract_retry_info_delay_seconds(
478+
api_error: errors.APIError,
479+
) -> Optional[float]:
480+
if api_error.code != 429 or api_error.status != 'RESOURCE_EXHAUSTED':
481+
return None
482+
483+
if not isinstance(api_error.details, dict):
484+
return None
485+
486+
for path in (['error', 'details'], ['details']):
487+
details = _common.get_value_by_path(api_error.details, path)
488+
if not isinstance(details, list):
489+
continue
490+
491+
for detail in details:
492+
if not isinstance(detail, dict):
493+
continue
494+
detail_type = detail.get('@type')
495+
if (
496+
not isinstance(detail_type, str)
497+
or not detail_type.endswith('google.rpc.RetryInfo')
498+
):
499+
continue
500+
retry_delay = _common.get_value_by_path(detail, ['retryDelay'])
501+
if not isinstance(retry_delay, str):
502+
continue
503+
retry_delay = retry_delay.strip()
504+
if not retry_delay.endswith('s'):
505+
continue
506+
try:
507+
retry_delay_seconds = float(retry_delay[:-1])
508+
except ValueError:
509+
continue
510+
if retry_delay_seconds >= 0:
511+
return retry_delay_seconds
512+
return None
513+
514+
477515
def retry_args(options: Optional[HttpRetryOptions]) -> _common.StringDict:
478516
"""Returns the retry args for the given http retry options.
479517
@@ -498,11 +536,28 @@ def retry_args(options: Optional[HttpRetryOptions]) -> _common.StringDict:
498536
exp_base=options.exp_base or _RETRY_EXP_BASE,
499537
jitter=options.jitter or _RETRY_JITTER,
500538
)
539+
fallback_wait = wait
540+
541+
def wait_with_retry_info(retry_state: tenacity.RetryCallState) -> float:
542+
if retry_state.outcome is not None and retry_state.outcome.failed:
543+
exception = retry_state.outcome.exception()
544+
if isinstance(exception, errors.APIError):
545+
retry_delay_seconds = _extract_retry_info_delay_seconds(exception)
546+
if retry_delay_seconds is not None:
547+
# Add one second because RetryInfo delay can be truncated.
548+
return retry_delay_seconds + 1
549+
return fallback_wait(retry_state)
550+
551+
# Preserve standard attributes.
552+
wait_with_retry_info.initial = wait.initial # type: ignore
553+
wait_with_retry_info.max = wait.max # type: ignore
554+
wait_with_retry_info.exp_base = wait.exp_base # type: ignore
555+
wait_with_retry_info.jitter = wait.jitter # type: ignore
501556
return {
502557
'stop': stop,
503558
'retry': retry,
504559
'reraise': True,
505-
'wait': wait,
560+
'wait': wait_with_retry_info,
506561
'before_sleep': tenacity.before_sleep_log(logger, logging.INFO),
507562
}
508563

google/genai/tests/client/test_retries.py

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import asyncio
1919
from collections.abc import Sequence
2020
import datetime
21+
import json
2122
from unittest import mock
2223
import pytest
2324
try:
@@ -61,11 +62,14 @@ def _final_codes(retried_codes: Sequence[int] = _RETRIED_CODES):
6162
return [code for code in range(100, 600) if code not in retried_codes]
6263

6364

64-
def _httpx_response(code: int):
65+
def _httpx_response(code: int, response_json=None):
66+
content = b''
67+
if response_json is not None:
68+
content = json.dumps(response_json).encode('utf-8')
6569
return httpx.Response(
6670
status_code=code,
6771
headers={'status-code': str(code)},
68-
content=b'',
72+
content=content,
6973
)
7074

7175

@@ -144,6 +148,99 @@ def fn():
144148
assert timestamps[4] - timestamps[3] >= datetime.timedelta(seconds=8)
145149

146150

151+
_RETRY_OPTIONS_NO_JITTER = types.HttpRetryOptions(
152+
attempts=2,
153+
initial_delay=0.25,
154+
max_delay=10,
155+
exp_base=2,
156+
jitter=0,
157+
)
158+
159+
160+
def _resource_exhausted_error_payload(
161+
retry_delay: str,
162+
*,
163+
status: str = 'RESOURCE_EXHAUSTED',
164+
wrapped: bool = True,
165+
):
166+
details = {
167+
'code': 429,
168+
'message': 'Resource exhausted.',
169+
'status': status,
170+
'details': [
171+
{
172+
'@type': 'type.googleapis.com/google.rpc.RetryInfo',
173+
'retryDelay': retry_delay,
174+
}
175+
],
176+
}
177+
if wrapped:
178+
return {'error': details}
179+
return details
180+
181+
182+
def _retry_and_capture_sleep(status_code: int, error_payload: dict[str, object]):
183+
def fn():
184+
errors.APIError.raise_for_response(_httpx_response(status_code, error_payload))
185+
186+
retrying = tenacity.Retrying(
187+
**api_client.retry_args(_RETRY_OPTIONS_NO_JITTER)
188+
)
189+
with mock.patch('tenacity.wait.random.uniform', return_value=0.0):
190+
with mock.patch('tenacity.nap.time.sleep') as mock_sleep:
191+
with pytest.raises(errors.APIError):
192+
retrying(fn)
193+
assert mock_sleep.call_count == 1
194+
return mock_sleep.call_args.args[0]
195+
196+
197+
def test_retry_wait_uses_retry_info_for_429_resource_exhausted():
198+
retry_delay_seconds = _retry_and_capture_sleep(
199+
429,
200+
_resource_exhausted_error_payload('21.943984799s'),
201+
)
202+
assert retry_delay_seconds == pytest.approx(22.943984799)
203+
204+
205+
def test_retry_wait_ignores_retry_info_when_status_not_resource_exhausted():
206+
retry_delay_seconds = _retry_and_capture_sleep(
207+
429,
208+
_resource_exhausted_error_payload(
209+
'9s', status='UNAVAILABLE'
210+
),
211+
)
212+
assert retry_delay_seconds == 0.25
213+
214+
215+
def test_retry_wait_ignores_retry_info_when_code_not_429():
216+
retry_delay_seconds = _retry_and_capture_sleep(
217+
500,
218+
_resource_exhausted_error_payload('9s'),
219+
)
220+
assert retry_delay_seconds == 0.25
221+
222+
223+
def test_retry_wait_falls_back_on_malformed_retry_delay():
224+
retry_delay_seconds = _retry_and_capture_sleep(
225+
429,
226+
_resource_exhausted_error_payload('invalid-delay'),
227+
)
228+
assert retry_delay_seconds == 0.25
229+
230+
231+
def test_retry_wait_supports_error_details_with_or_without_error_wrapper():
232+
wrapped_retry_delay = _retry_and_capture_sleep(
233+
429,
234+
_resource_exhausted_error_payload('3.5s', wrapped=True),
235+
)
236+
unwrapped_retry_delay = _retry_and_capture_sleep(
237+
429,
238+
_resource_exhausted_error_payload('3.5s', wrapped=False),
239+
)
240+
assert wrapped_retry_delay == pytest.approx(4.5)
241+
assert unwrapped_retry_delay == pytest.approx(4.5)
242+
243+
147244
def test_retry_args_enabled_with_custom_values_are_not_overridden():
148245
options = types.HttpRetryOptions(
149246
attempts=10,

0 commit comments

Comments
 (0)