feat(retry): transient transport failures back off and retry - #171
Conversation
The 2026-08-27 gpt-deliberate failure (explorer 429 TPM, no backoff, whole investigation lost) is the motivating case. Verified against vendored rig 0.41 that nothing upstream covers the non-streaming path: RetryPolicy is SSE-only, agent hooks never see transport errors, and Retry-After is dropped before the error reaches us. The existing Retried<M> wrapper (completion_retry.rs) is the seam; M1 adds a transient-transport class with backoff there, M2 adds a governor-based per-backend requests-per-minute limiter, M3 (Retry-After on owned reqwest surfaces) is recorded and deferred. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…etried seam The 2026-08-27 failure is the motivating case: a gpt-deliberate explorer hit a 429 TPM limit and the whole investigation died — kaibo neither waited nor asked again. The Retried wrapper already owned 'kaibo sends a provider request twice' for malformed generations, so the transient class lives there rather than in a sibling: statuses 429/500/502/503/529 via provider_response_status(), with conservative body markers only when no status survived. Exponential backoff with full jitter (rig's buffer_unordered fan-out means synchronized retries would re-collide), a body-parsed delay hint as a floor (rig drops the Retry-After header before the error reaches us), and independent bounds per failure class. Timeouts deliberately excluded: a timed-out request may be a request that is too large, and retrying it doubles the spend to fail twice. Plan: docs/rate-limits.md (M1). Failing-first: the two behavioral tests were run against the pre-M1 loop and failed for the right reason before the implementation landed. 1237 tests green, clippy clean, aws-lc-rs and mimalloc trees still empty. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cross-family reviewkaibo cast The blocking finding — a panic on a huge delay hint. The rest, confirmed correct:
Reviewed-by: kaibo cast |
The cross-family review's one blocking finding, reproduced failing-first: a finite-but-huge hint (twenty digits of seconds) passes the finiteness guard, and Duration::from_secs_f64 panics on overflow before the .min(cap) on the converted value can run. The cap now applies to the number, then the conversion — a hostile or buggy 429 body waits 60s instead of killing the phase with a panic. Reviewed-by: kaibo cast crusoe (DeepSeek-V4-Flash explorer, GLM-5.2 synth) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Transient provider failures — rate limits and overloads — now back off and retry at the
Retriedseam instead of killing the whole call. M1 of the plan indocs/rate-limits.md, which rides this PR.The incident behind it
2026-08-27: two
deliberatecalls ongpt-deliberatedied in the DOSSIER phase when the explorer hit a 429 TPM limit on three attached files. kaibo neither waited nor asked again, and each whole investigation was lost — the failure mode this fixes is a many-turn phase forfeited to one transient refusal.Why this lives in kaibo and not rig
Verified against the vendored rig 0.41 sources before building: rig-core's
RetryPolicy/ExponentialBackoffis wired only into the SSE event source (streaming reconnects — kaibo's loop is non-streaming); rig-agent'sModelTurnAction::Retryre-rolls completed turns and never sees a transport error, which exits the run asErr. The one thing rig does hand back isprovider_response_status()— and its error path drops response headers (non_success_status_errorkeeps status + body), soRetry-Afteris unrecoverable on this path. That is why the delay hint is body-parsed and best-effort, with computed backoff as the floor.The design
Retried<M>already retried malformed generations at theCompletionModelseam, underneath rig's loop where the transcript still exists; the transient class extends it rather than adding a sibling wrapper. Two independent counters — a turn that fumbles once and then gets rate-limited spends both allowances.provider_response_status(), matched as specific codes (never!is_success()). Conservative phrase markers (overloaded_error,rate limit,too many requests) fire only when no status survived; a bare "429" substring deliberately cannot.buffer_unordered) would otherwise re-collide on a synchronized clock. Floor 1s, factor 2, per-wait cap 60s, 4 further attempts — constants until evidence says a deployment needs config.Validation
Failing-first: the two behavioral tests were run against the pre-M1 loop and failed for the right reason (a 429 passed straight through). 11 module tests including paused-clock timing bounds; full suite green post-rebase; clippy clean;
rand/http(dev-only) were already transitive —Cargo.lockgained zero new crates;cargo tree -i aws-lc-rs/-i mimallocboth empty.M2 (a
governor-based per-backend requests-per-minute limiter, opt-in) and M3 (Retry-Afteron kaibo-owned reqwest surfaces, deferred) are scoped in the plan doc.A cross-family kaibo review follows as a PR comment.
Co-authored-by: Claude Sonnet 5 (implementation), reviewed in-session by Claude Fable 5.
🤖 Generated with Claude Code