Skip to content

Commit e2546b0

Browse files
MarkDaoustcopybara-github
authored andcommitted
fix(interactions): retries = attempts - 1
`HttpRetryOptions.attempts` counts the original request, so 0 and 1 both mean no retries. `_translate_retry_config` previously passed `attempts` straight through as `RetryConfig.max_retries` (which counts retries after the initial request), causing `attempts=0` and `attempts=1` to still retry once. Map `attempts` to `max(attempts - 1, 0)`. When `attempts` is unset, Interactions now uses the same default as the rest of the SDK (`_api_client._RETRY_ATTEMPTS`, 5 attempts including the initial request) instead of its own constant (previously 4 attempts). Fixes #3260 PiperOrigin-RevId: 1013786410
1 parent a216d95 commit e2546b0

2 files changed

Lines changed: 170 additions & 4 deletions

File tree

‎google/genai/_gaos/google_genai.py‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
except ImportError:
3838
import httpx2 as httpx
3939

40+
from .. import _api_client
4041
from .. import _interactions_continuation
4142
from ._hooks.google_genai_auth import (
4243
GOOGLE_GENAI_API_REVISION as _GOOGLE_GENAI_API_REVISION,
@@ -144,7 +145,6 @@ def _get_google_genai_security(api_client: Any) -> Optional[Any]:
144145
return None
145146

146147

147-
_DEFAULT_MAX_RETRIES = 2
148148
# Default backoff shape (ms): 0.5s initial, 8s cap, base 2. Individual fields
149149
# are overridden by the matching `retry_options` value when set.
150150
_DEFAULT_INITIAL_INTERVAL_MS = 500
@@ -156,15 +156,19 @@ def _get_google_genai_security(api_client: Any) -> Optional[Any]:
156156
def _translate_retry_config(http_options: Any) -> RetryConfig:
157157
"""Maps parent `HttpOptions.retry_options` onto a `RetryConfig`.
158158
159-
`attempts` becomes the retry count (an unset `attempts` falls back to the
160-
default retry count). The delay-shaping options (`initial_delay`,
159+
`attempts` counts the original request, so `attempts - 1` becomes the retry
160+
count: 0 and 1 both mean no retries (an unset `attempts` falls back to
161+
`_api_client._RETRY_ATTEMPTS`, shared with the rest of the SDK). The
162+
delay-shaping options (`initial_delay`,
161163
`max_delay`, `exp_base`, `jitter`) and `http_status_codes` are honored when
162164
provided, each defaulting to the built-in behavior when omitted.
163165
"""
164166
options = getattr(http_options, 'retry_options', None)
165167

166168
attempts = getattr(options, 'attempts', None) if options is not None else None
167-
max_retries = attempts if attempts is not None else _DEFAULT_MAX_RETRIES + 1
169+
if attempts is None:
170+
attempts = _api_client._RETRY_ATTEMPTS
171+
max_retries = max(attempts - 1, 0)
168172

169173
initial_interval = _DEFAULT_INITIAL_INTERVAL_MS
170174
max_interval = _DEFAULT_MAX_INTERVAL_MS
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
16+
17+
"""Tests for how `HttpRetryOptions` applies to the Interactions API."""
18+
19+
from unittest import mock
20+
21+
import pytest
22+
23+
from ... import _api_client
24+
from ... import Client
25+
from ... import types
26+
from ..._gaos import google_genai
27+
from ..._gaos.lib import compat_errors
28+
29+
if _api_client._get_http_client_backend() == 'httpx2':
30+
from httpx2 import Client as HTTPClient, Request, Response # pylint: disable=g-import-not-at-top
31+
32+
AsyncHttpxClient = _api_client.AsyncHttpx2Client
33+
else:
34+
from httpx import Client as HTTPClient, Request, Response # pylint: disable=g-import-not-at-top
35+
36+
AsyncHttpxClient = _api_client.AsyncHttpxClient
37+
38+
ENV_VARS = [
39+
'GOOGLE_CLOUD_PROJECT',
40+
'GEMINI_API_KEY',
41+
'GOOGLE_API_KEY',
42+
'GOOGLE_CLOUD_LOCATION',
43+
]
44+
45+
46+
@pytest.fixture(autouse=True)
47+
def clear_env_vars(monkeypatch):
48+
for var in ENV_VARS:
49+
monkeypatch.delenv(var, raising=False)
50+
51+
52+
def _rate_limited():
53+
return Response(
54+
429,
55+
request=Request('POST', ''),
56+
headers={'retry-after-ms': '1', 'content-type': 'application/json'},
57+
content='{"error": {"code": "too_many_requests", "message": "quota"}}',
58+
)
59+
60+
61+
def _completed():
62+
return Response(
63+
200,
64+
request=Request('POST', ''),
65+
headers={'content-type': 'application/json'},
66+
content='{"status": "completed"}',
67+
)
68+
69+
70+
@pytest.mark.parametrize(
71+
'attempts, expected_retries',
72+
[
73+
(None, google_genai._api_client._RETRY_ATTEMPTS - 1),
74+
(0, 0),
75+
(1, 0),
76+
(2, 1),
77+
(5, 4),
78+
],
79+
)
80+
def test_translate_retry_config_counts_the_original_request(
81+
attempts, expected_retries
82+
):
83+
retry_options = None
84+
if attempts is not None:
85+
retry_options = types.HttpRetryOptions(attempts=attempts)
86+
http_options = types.HttpOptions(retry_options=retry_options)
87+
88+
config = google_genai._translate_retry_config(http_options)
89+
90+
assert config.max_retries == expected_retries
91+
92+
93+
@pytest.mark.parametrize('attempts', [0, 1])
94+
def test_interactions_do_not_retry_when_attempts_disable_retries(
95+
monkeypatch, attempts
96+
):
97+
monkeypatch.setenv('GOOGLE_API_KEY', 'test-api-key')
98+
client = Client(
99+
http_options=types.HttpOptions(
100+
retry_options=types.HttpRetryOptions(attempts=attempts)
101+
)
102+
)
103+
104+
with mock.patch.object(HTTPClient, 'send') as mock_send:
105+
mock_send.side_effect = [_rate_limited(), _rate_limited()]
106+
with pytest.raises(compat_errors.RateLimitError):
107+
client.interactions.create(model='gemini-1.5-flash', input='Hello')
108+
assert mock_send.call_count == 1
109+
110+
111+
@pytest.mark.asyncio
112+
@pytest.mark.parametrize('attempts', [0, 1])
113+
async def test_async_interactions_do_not_retry_when_attempts_disable_retries(
114+
monkeypatch, attempts
115+
):
116+
monkeypatch.setenv('GOOGLE_API_KEY', 'test-api-key')
117+
client = Client(
118+
http_options=types.HttpOptions(
119+
retry_options=types.HttpRetryOptions(attempts=attempts)
120+
)
121+
)
122+
123+
with mock.patch.object(AsyncHttpxClient, 'send') as mock_send:
124+
mock_send.side_effect = [_rate_limited(), _rate_limited()]
125+
with pytest.raises(compat_errors.RateLimitError):
126+
await client.aio.interactions.create(
127+
model='gemini-1.5-flash', input='Hello'
128+
)
129+
assert mock_send.call_count == 1
130+
131+
132+
def test_interactions_retry_up_to_attempts(monkeypatch):
133+
monkeypatch.setenv('GOOGLE_API_KEY', 'test-api-key')
134+
client = Client(
135+
http_options=types.HttpOptions(
136+
retry_options=types.HttpRetryOptions(attempts=3)
137+
)
138+
)
139+
140+
with mock.patch.object(HTTPClient, 'send') as mock_send:
141+
mock_send.side_effect = [_rate_limited(), _rate_limited(), _completed()]
142+
client.interactions.create(model='gemini-1.5-flash', input='Hello')
143+
assert mock_send.call_count == 3
144+
145+
with mock.patch.object(HTTPClient, 'send') as mock_send:
146+
mock_send.side_effect = [_rate_limited()] * 4
147+
with pytest.raises(compat_errors.RateLimitError):
148+
client.interactions.create(model='gemini-1.5-flash', input='Hello')
149+
assert mock_send.call_count == 3
150+
151+
152+
def test_interactions_default_matches_generate_content_attempts(monkeypatch):
153+
monkeypatch.setenv('GOOGLE_API_KEY', 'test-api-key')
154+
client = Client(
155+
http_options=types.HttpOptions(retry_options=types.HttpRetryOptions())
156+
)
157+
158+
with mock.patch.object(HTTPClient, 'send') as mock_send:
159+
mock_send.side_effect = [_rate_limited()] * 6
160+
with pytest.raises(compat_errors.RateLimitError):
161+
client.interactions.create(model='gemini-1.5-flash', input='Hello')
162+
assert mock_send.call_count == google_genai._api_client._RETRY_ATTEMPTS

0 commit comments

Comments
 (0)