Skip to content

fix(openai): honor Gemini RetryInfo retryDelay on 429/503 retries - #271

Merged
plombeer31 merged 1 commit into
mainfrom
fix/issue-106-openai-retry-handling-ignores
Aug 31, 2026
Merged

fix(openai): honor Gemini RetryInfo retryDelay on 429/503 retries#271
plombeer31 merged 1 commit into
mainfrom
fix/issue-106-openai-retry-handling-ignores

Conversation

@plombeer31

@plombeer31 plombeer31 commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

What was broken

Gemini's OpenAI-compatible endpoint advertises its cooldown only as structured error JSON — a google.rpc.RetryInfo entry in error.details[] whose retryDelay is a protobuf Duration string ("39s", "1.5s") — and sends no Retry-After header. httpErrorFromResponse in src/llm/provider/openai/openai-http.ts derived retryAfterMs only from the header, so resolveWaitMs never saw the provider cooldown and throttled requests exhausted all three attempts on the 150/300 ms exponential backoff before the advertised cooldown elapsed.

The fix

When the retry-after header is absent on a 429/503, httpErrorFromResponse now falls back to parseRetryInfoDelayMs(), which JSON-parses the already-read body, walks error.details[] for a string retryDelay matching ^(\d+(?:\.\d+)?)s$, and returns milliseconds. The value flows through the existing retryAfterMs field, so nothing else changes:

  • a valid Retry-After header still takes precedence;
  • resolveWaitMs still caps the wait at OPENAI_RETRY_AFTER_CAP_MS (5 s), so a "39s" cooldown cannot stall an interactive turn or sleep unboundedly;
  • the cancellation-aware sleep() still lets a caller abort interrupt the wait;
  • malformed, negative, or non-string durations are ignored and the plain backoff applies;
  • deterministic 4xx behavior and the metadata gate (429/503 only) are untouched.

Test evidence

New fake-timer tests (pinned Math.random zeroes the jitter; no wall-clock delay) in src/llm/provider/openai/openai-http.test.ts cover every acceptance criterion: headerless 429 with "1.5s" and 503 with "2s" control the retry timing, "not-a-duration" / "-1s" / non-string values fall back to the 150 ms backoff, a valid header beats the structured delay, "39s" is capped at 5 s, a 500 carrying RetryInfo-shaped JSON ignores it, and caller abort interrupts the pending wait.

  • npx vitest run src/llm/provider/openai — 95 passed (9 files)
  • with the source fix stashed, the three behavior-bearing new tests fail (429/503 honoring + cap), confirming they gate the fix
  • npm run lint (tsc --noEmit) — clean
  • npm run build — clean

Note: open PR #267 addresses the same issue with an equivalent approach; this PR was produced from an independent verification pass against v0.4.2 main (4caff55) and adds a regression test that the 429/503-only gate holds. Maintainers can merge either.

Fixes #106

Gemini's OpenAI-compatible endpoint advertises its cooldown only as
google.rpc.RetryInfo inside the error JSON (error.details[].retryDelay,
a protobuf Duration like "39s") and sends no retry-after header. The
retry loop derived retryAfterMs solely from the header, so throttled
requests burned all three attempts on the 150/300ms backoff before the
provider's cooldown had elapsed.

httpErrorFromResponse now falls back to parsing that structured delay
from the already-read body on 429/503 when the header is absent. The
value flows through the existing retryAfterMs field, so header
precedence, the 5s interactive cap in resolveWaitMs, and the
cancellation-aware sleep all apply unchanged; malformed or negative
durations are ignored. Fake-timer tests cover each acceptance criterion
of #106.

Fixes #106

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@plombeer31
plombeer31 merged commit 6731c29 into main Aug 31, 2026
2 checks passed
plombeer31 pushed a commit that referenced this pull request Aug 31, 2026
…6-08-31

# Conflicts:
#	src/agent/step-executor.ts
#	src/config/config-schema.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant