Skip to content

fix: make retry backoff able to clear a rate-limit quota window - #72

Merged
JNK234 merged 2 commits into
mainfrom
fix/retry-backoff
Aug 13, 2026
Merged

fix: make retry backoff able to clear a rate-limit quota window#72
JNK234 merged 2 commits into
mainfrom
fix/retry-backoff

Conversation

@JNK234

@JNK234 JNK234 commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Closes #65.

Why

Retry-with-backoff (#37) correctly detected 429s but could not satisfy any provider asking for more than 10 seconds. Three defects, all visible in one real Gemini free-tier response asking for 18.5s:

  1. MaxDelayMs capped at 10s — every retry fired inside the window and was guaranteed to fail
  2. Only the Retry-After header was parsed — Gemini returns the delay in the JSON body (error.details[].RetryInfo.retryDelay), so the one piece of information that would have made the retry succeed was discarded
  3. Total budget ~7s (3 retries, 1s/2s/4s) against a 60s per-minute window — could not recover from an RPM limit by construction

Gemini's free tier is 5 requests/minute. Any model calling an LLM per agent per tick exceeds that on tick one, so this is the common case, not an edge case.

What

  • New RetryPolicy — retry logic as a testable value type, separate from the HTTP loop. This is what allows testing 60s+ budget behaviour without sleeping.
  • Delay is parsed from the response body as well as the header: Gemini RetryInfo, prose in error.message, and the Retry-After header (which wins when both are present)
  • MaxDelayMs/MaxRetries replaced with config-driven bounds; total budget defaults to 65s so it can actually clear a per-minute window
  • Jitter added — deterministic backoff across many agents retries in lockstep, which is precisely the ABM case
  • Waits ≥2s print a stderr note naming provider, duration, and attempt, so a long stall does not look like a hang. Shorter backoffs stay silent.
  • Session counters for rate-limit waits (plumbing only; no new NetLogo primitive)

Design calls

timeout_seconds interaction. That key is the request budget; sitting out a provider's quota window is not the request being slow. The await bound is now timeout_seconds + retry_budget, so lowering timeout_seconds does not silently forfeit rate-limit recovery.

Bounded, not unlimited. Unbounded waiting turns a quota misconfiguration into an indistinguishable hang. Budget exhaustion produces actionable guidance — the real fix for 5 RPM against 200 agents is fewer requests, so the message leads with that.

Behaviour change worth noting

A persistent 429 now takes ~65s to fail instead of ~7s. That is the intended trade — failing fast on a rate limit meant failing always — but anyone relying on quick failure will notice. Both bounds are configurable, and non-rate-limit errors still fail immediately.

Tests

74 passing, 0 failed (baseline 54, +20). No live API calls; run three times consecutively with identical results. sbt assembly clean under -Xfatal-warnings.

One pre-existing test was modified and this deserves review: "fails after exhausting retries on persistent 429" hardcoded "after 4 attempts", which asserted the old 3-retry default — the exact thing being fixed. It now pins retry_max_retries=3 so it still asserts exhaustion behaviour, and a separate test asserts the new default is greater than 3.

Left out deliberately

  • No NetLogo primitive for the wait counters — new API surface beyond this bug
  • No sttp socket-timeout wiring. Worth flagging: timeout_seconds is currently only applied as an Await bound in LLMExtension, never as a socket timeout on the request itself. Separate latent gap, left alone.

JNK234 added 2 commits August 13, 2026 16:50
The 429 retry path could not recover from a per-minute quota limit by
construction, which is the common case on Gemini's free tier (5 RPM) for any
model calling an LLM per agent per tick.

- Raise the per-sleep ceiling: the fixed 10s MaxDelayMs truncated Google's
  actual 18.5s ask, so every retry fired inside the still-closed window and was
  guaranteed to fail. A single delay is now capped by the policy's maxDelay
  (64s) and bounded overall by a total elapsed budget.
- Parse the provider's requested delay from the response BODY, not just the
  Retry-After header. Gemini returns it only in
  error.details[].RetryInfo.retryDelay (and in error.message prose); reading
  the header alone discarded the one value that would make the retry succeed.
  Exposed as the parseRetryDelayMs hook for per-provider overrides.
- Raise the total retry budget: 3 retries at 1s/2s/4s spent ~7s, far short of
  the 60s an RPM window needs. Default is now 6 retries / 65s, tunable via
  retry_max_retries and retry_max_elapsed_seconds.
- Add jitter (25%, additive only) so agents rate-limited by the same window do
  not retry in lockstep - the deterministic backoff was worst-case for ABM.
  Jitter never shortens a provider-requested delay.
- Waiting out a quota window no longer counts against timeout_seconds. That
  budget covers the request itself, so the await bound is now the request
  timeout plus the retry budget; lowering timeout_seconds no longer forfeits
  rate-limit recovery.
- Surface long waits: waits >=2s print a stderr note (a silent 20-60s stall in
  `go` reads as a hang) and increment session counters. Budget exhaustion now
  returns actionable guidance that the quota may be too low for the simulation.

Risk: default behavior now waits materially longer before failing a persistent
429 (up to ~65s vs ~7s). This is intended - failing fast on a rate limit meant
failing always. Both bounds are configurable, and non-rate-limit errors still
fail immediately.

Refs #65
Retry-After: 0 parses successfully to Some(0), so short-circuiting on the
header discarded a body-supplied delay entirely. A 429 carrying both
Retry-After: 0 and Gemini's retryDelay: "18.5s" retried after the local
1s backoff — inside the quota window, guaranteed to fail. That is the
exact defect this policy exists to fix, reachable through the fix itself.

Neither source may mask the other, so both are parsed and the longer wins.

The previous test asserted header-wins as intended behaviour and is
replaced by one covering both directions, plus a regression test pinning
the Retry-After: 0 case.

Also corrects two documented claims that measurement contradicted:
- maxDelay is a ceiling on the computed backoff, applied BEFORE jitter, so
  an actual sleep can exceed it by up to jitterFactor (25% by default)
- a persistent 429 fails after roughly 31-39s of waiting, not the 65s
  budget: canRetry refuses the sixth sleep, and the old comment's
  1+2+4+8+16+32 sums to 63s rather than exceeding 60s of additional wait
- maxElapsed bounds scheduled SLEEP, not wall-clock; request time is not
  counted

75 tests pass.
@JNK234
JNK234 merged commit a06a6f2 into main Aug 13, 2026
2 checks passed
@JNK234
JNK234 deleted the fix/retry-backoff branch August 14, 2026 00:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: retry backoff caps at 10s and ignores provider-requested delay, so free-tier quota windows always fail

1 participant