Skip to content

Provider error taxonomy + retry/backoff ([A6] / #13) - #61

Merged
ammar-s847 merged 3 commits into
mainfrom
claude/hebb-issues-prs-FQagq-a6-retry-backoff
May 28, 2026
Merged

Provider error taxonomy + retry/backoff ([A6] / #13)#61
ammar-s847 merged 3 commits into
mainfrom
claude/hebb-issues-prs-FQagq-a6-retry-backoff

Conversation

@ammar-s847

Copy link
Copy Markdown
Contributor

Closes #13.

Summary

Adds the typed-error classification + retry-loop the agent harness ([C2]) needs around LlmProvider::complete:

  • ProviderError gains RateLimit { message, retry_after } and Transient(message) variants; Transport is reclassified as retryable.
  • ProviderError::is_retryable() / retry_after() drive the retry decision tree.
  • retry_with_backoff(cfg, op) wraps an async closure with exponential backoff, jitter, a max-delay cap, and Retry-After honouring.
  • Gemini's classify_http_error maps 429 → RateLimit (with header), 5xx → Transient; everything else stays where it was.

Commits

  1. Extend ProviderError with retry classification ([A6] / #13) — new variants + helpers + tests.
  2. Add retry_with_backoff helper for transient provider failures ([A6] / #13) — backoff helper module + tests.
  3. Classify Gemini HTTP errors into retry-aware variants ([A6] / #13) — Gemini wire mapping + Retry-After parsing + tests.

Design

Retryable vs. not. Auth, InvalidRequest, Decode, and the unmodeled Provider bucket are not retried — retrying does not change the answer, or worse, retrying a request the server rejected for safety / quota reasons hammers it. Transport, Transient, and RateLimit are retried.

Backoff schedule. RetryConfig::interactive() (default): 3 retries, 250 ms initial, 8 s cap, ±25% jitter. RetryConfig::aggressive(): 6 retries, 500 ms initial, 30 s cap. RetryConfig::no_wait(n): zero-delay, used by tests to exercise the loop without tokio::time::pause() boilerplate.

Retry-After. When set on a RateLimit, the helper uses it verbatim (still capped at max_delay so a misbehaving provider can't pin the loop on a multi-minute wait).

Jitter. Applied to the computed delay so concurrent sessions don't synchronize a thundering herd on the same provider after a shared incident.

The helper is pure async — no tokio::spawn, no task ownership — so the reasoning loop ([C2]) can call it inline around provider.complete() without restructuring the call shape.

Test plan

  • cargo test -p core retry — 9 passed (delay table, jitter window, Retry-After precedence, retry-budget exhaustion, success-stops-loop, non-retryable-not-retried).
  • cargo test -p core provider::gemini — 11 passed, 1 ignored (existing live smoke). Covers 429 + Retry-After, 5xx, unmodeled 4xx, Retry-After parser.
  • cargo test -p core provider::tests — 8 passed. Includes is_retryable table and retry_after exposure.
  • cargo test -p core — 101 passed, 1 ignored, no regressions.

Out of scope


Generated by Claude Code

claude added 3 commits May 28, 2026 03:23
The agent retry loop needs to distinguish errors it should retry from
errors retrying will not fix. Two new variants:

- `ProviderError::RateLimit { message, retry_after }` — typically
  HTTP 429. Carries the server's `Retry-After` hint when present so
  the loop can honour it instead of clobbering it with its own
  schedule.
- `ProviderError::Transient(message)` — typically HTTP 5xx and
  request-level timeouts. Retried with exponential backoff.

Reclassification:

- `Transport` becomes retryable. TCP resets, DNS hiccups, and proxy
  blips should not bubble up as hard failures on the first try.
- `Provider` (the catch-all bucket) stays non-retryable. Anything we
  decide is worth retrying should land in `Transient` explicitly so
  we don't hammer a provider with a request it rejected for a
  non-transient reason.

Two helpers drive the retry decision: `is_retryable()` returns the
retry/no-retry table, and `retry_after()` exposes the rate-limit
hint to the loop. The provider impls (Gemini, future Anthropic via
[A4]) will map their wire formats to these variants in follow-ups.
…13)

`retry_with_backoff` wraps an arbitrary `LlmProvider::complete`-shaped
closure and retries on errors `ProviderError::is_retryable` flags.
Behaviour:

- Non-retryable errors return immediately. The reasoning loop never
  hammers a provider with a request it has already rejected for a
  non-transient reason (bad key, malformed request, safety filter).
- Retryable errors back off exponentially: `initial × 2^attempt`,
  capped at `max_delay` so a high attempt count can't pin the loop on
  a multi-minute wait.
- Jitter is applied to the computed delay so concurrent sessions
  don't synchronize a thundering herd on the same provider after a
  shared incident.
- A `Retry-After` hint on `RateLimit` overrides the computed delay
  (still capped at `max_delay`) so a server's "wait 30s" is honoured
  exactly.

`RetryConfig::interactive()` is the default — 3 retries, 250 ms
initial, 8 s cap. `RetryConfig::aggressive()` is the patient batch
profile. `RetryConfig::no_wait(n)` makes retry-loop tests deterministic
without `tokio::time::pause()` boilerplate.

The helper is pure async — no `tokio::spawn`, no task ownership — so
the reasoning loop ([C2]) can call it inline around `provider.complete()`
without restructuring the call shape.
The Gemini provider now maps HTTP status into the variants the retry
helper branches on:

- 429 → `ProviderError::RateLimit`, with `retry_after` filled from the
  response's `Retry-After` header (integer-seconds form only — the
  HTTP-date alternative falls back to exponential backoff).
- 500-599 → `ProviderError::Transient`. The retry loop will back off
  and try again, preserving the status code in the message for the
  debug log.

The existing buckets are unchanged: 400 stays `InvalidRequest`,
401/403 stay `Auth`, anything else 4xx stays in the unmodeled
`Provider` catch-all (not retried).

Tests cover each branch, plus the `parse_retry_after` shape — an
integer header value round-trips, a date-form value yields `None`, a
missing header yields `None`.
@ammar-s847
ammar-s847 merged commit 4c4a56e into main May 28, 2026
2 checks passed
@ammar-s847
ammar-s847 deleted the claude/hebb-issues-prs-FQagq-a6-retry-backoff branch May 28, 2026 16:51
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.

[A6] Provider error taxonomy + retry/backoff

2 participants