Marketing

Retry Budgets for Agent Calls: Backoff and Jitter

Antonio Evans7 min read

A clean line diagram on a light background: a client box on the left, three attempts drawn as arrows to a model API box on the right.
Key points

Key points

  1. 1Retry only transient failures (429, 503, connection resets, timeouts), and never retry 400, 401, 403, 404 or 422, because those errors will repeat identically.
  2. 2Use full jitter: sleep a random value between 0 and min(cap, base * 2^attempt) so a fleet of clients does not retry in lockstep.
  3. 3Cap attempts per call at 3 to 5 and add a global budget so retries stay under a fixed share of total calls in a rolling window.
  4. 4When retries run out, return a typed error carrying the last status, the attempt count and the total wait, and let the caller choose a fallback.

Classify the error before you retry anything

The first decision in any retry policy is which failures deserve a second attempt. Retrying a permanent error wastes quota, adds latency, and on a shared upstream can look like abuse. A 400 means your payload is malformed, a 401 means the key is wrong or revoked, a 403 means the endpoint rejects you, a 404 means the path does not exist, and a 410 means it was removed for good. None of those get better with time, and general HTTP retry guidance says to move them straight to a dead-letter path instead of looping Hookdeck's retry guidance.

Transient failures are the opposite: they resolve on their own. Connection timeouts, connection refused, connection resets, and temporary DNS failures all fall in this bin, as do 500, 502, 503 and 504, because they signal a server-side problem that often clears in seconds Hookdeck's retry guidance. 429 is a special case: the upstream is asking you to slow down, so honor its Retry-After header when present and otherwise back off more aggressively than usual Hookdeck's retry guidance.

Model APIs add nuance. A 429 that means "your per-minute budget is spent" is transient and worth retrying, but a 429 that means "your credits ran out" is a wall, and looping on it only burns your own CPU. The published error guides separate rate limits, provider overload (503 and overload codes), and quota or billing exhaustion, and they are explicit that retrying billing errors will not restore access AI API error and retry guide. Read the error body, not just the status code, before you decide.

The second axis is idempotency. A GET is safe to replay. A POST that creates a resource is not, unless the server can deduplicate it. HTTP defines idempotency in terms of intended effect: repeating identical requests should have the same effect as making one Stripe's advanced error handling. Stripe implements this with client-generated idempotency keys, reused across retries of the same logical operation, so a retry after a lost response returns the original result instead of charging twice Stripe idempotent requests. If your model call triggers a side effect (writing a record, sending a message, charging a card), send an idempotency key or do not retry it at all.

A decision table with numbers

The table below is a starting policy, not a law. Storm cost is the extra requests you should expect if every client retries at the same moment with the same settings, expressed as a multiple of the original traffic. With 1,000 concurrent calls all failing at once, a policy that retries once adds 1,000 requests; the multiplier column shows that in relative terms.

Error classRetry?Max attempts per callBackoff window (base 0.5s, cap 8s)Storm cost at 1,000 calls
429 with Retry-AfterYes, after the stated wait3Honor header, then full jitterUp to 2x if every caller retries
429 without header (rate limit)Yes30 to min(8, 0.5 * 2^n)Up to 2x
429 with billing or spend codeNo1None1x
500, 502, 504Yes, only if the call is idempotent-safe30 to min(8, 0.5 * 2^n)Up to 2x
503 or provider overloadYes50 to min(8, 0.5 * 2^n)Up to 4x
Connection reset, refused, DNSYes50 to min(8, 0.5 * 2^n)Up to 4x
Timeout (no response)Yes only with an idempotency key30 to min(8, 0.5 * 2^n)Up to 2x
400, 401, 403, 404, 410, 422No1None1x

Read the last column as your worst-case exposure before the global budget throttles it. A 503 storm that lets every call retry four times can briefly quadruple load on an upstream that is already struggling. The budget in a later section exists to prevent exactly that.

Backoff and jitter: pick the formula, then the cap

Plain exponential backoff is delay = base * 2^attempt, capped at some maximum. With a one-second base the first retry waits one second, then two, four, eight, and so on Svix on exponential backoff. That schedule is fast enough to catch a blip and slow enough to survive a longer outage, which is why webhook delivery systems use it Svix on exponential backoff.

Without jitter, every client that failed at the same instant retries at the same instant. The AWS Architecture Blog calls this the thundering herd and shows that un-jittered retries produce a sawtooth that overloads the server at every boundary AWS on exponential backoff and jitter. Three jitter formulas are in common use:

# full jitter: best server-side behavior, slightly slower completion
sleep = random(0, min(cap, base * 2 ** attempt))

# equal jitter: at least half the exponential delay, still spreads load
sleep = expo / 2 + random(0, expo / 2)

# decorrelated jitter: upper bound depends on the previous sleep
sleep = min(cap, base + random(0, previous_sleep * 3 - base))

Brooker's measurements found that full jitter does the least total work and puts the lowest load on the server, at the cost of slightly longer time to completion. Decorrelated jitter finishes a little faster but does more total work AWS on exponential backoff and jitter. For agent calls, full jitter is the right default: your upstream is usually the bottleneck, not your own latency.

Cap the delay. Unbounded growth produces absurd waits: after 10 attempts with a 1-second base you would be sleeping over 17 minutes, and after 20 attempts, over 12 days Hookdeck's retry guidance. A cap of a few seconds suits interactive agent calls. Longer caps only make sense for background jobs where nobody is waiting on the response.

Set a deadline as well as an attempt cap. Six attempts at 30 seconds each will burn three minutes you may not have, so treat total wall-clock time as the primary gate and the attempt count as a safety net a TypeScript retry implementation.

Attempt caps and a global retry budget

Per-call caps stop a single request from looping. A cap of 3 to 5 attempts is the common range for webhook and API delivery: enough to ride out a short blip, short of the point where a loop looks like an attack 2POINT on webhook retries. AWS SDKs expose the same idea through a configurable maximum attempt count and a retry mode that decides how failures are classified AWS SDK retry behavior.

Per-call caps are not enough on their own. If 10 percent of your calls fail and each retries three times, you have added 30 percent to your request volume during exactly the window when the upstream is weakest. That is why you also need a global budget: a counter shared across the process or fleet that limits retries to a fixed share of total calls in a rolling window. A budget of 10 percent means that once retries reach one in ten of the calls you have made, further failures return to the caller immediately instead of retrying.

import random, time

RETRYABLE = {429, 500, 502, 503, 504}
BASE, CAP, MAX_ATTEMPTS = 0.5, 8.0, 4

def call_with_retry(fn, budget, attempt_fn, sleep=time.sleep):
    attempts, waited = 0, 0.0
    while True:
        attempts += 1
        try:
            return fn()
        except Exception as err:
            status = getattr(err, "status", None)
            if status not in RETRYABLE or attempts >= MAX_ATTEMPTS:
                raise RetriesExhausted(status, attempts, waited) from err
            if not budget.take():          # rolling-window counter, e.g. 10% of calls
                raise RetriesExhausted(status, attempts, waited) from err
            expo = min(CAP, BASE * 2 ** (attempts - 1))
            delay = random.uniform(0, expo)   # full jitter
            waited += delay
            sleep(delay)

Two properties make this loop safe to ship. It never retries a status outside the set, and it checks the budget before sleeping, so a storm cannot queue thousands of pending sleeps. The AWS Prescriptive Guidance frames the same pattern as a way to add stability to operations that fail on throttling or temporary unavailability, with the caveat that frequent retries can themselves consume bandwidth and cause contention AWS retry with backoff pattern.

Report exhausted retries as a typed error

The worst outcome is a bare exception that says "request failed" after 40 seconds of retrying. The caller has no idea whether to fall back, render a cached answer, or tell the user to try again. Return a structured error instead:

{
  "error": "retries_exhausted",
  "last_status": 503,
  "attempts": 4,
  "total_wait_ms": 6320,
  "retry_after": null,
  "trace_id": "01J9..."
}

Four fields do the work. last_status tells the caller whether the upstream was overloaded or unreachable. attempts tells it whether the policy ran its full course or the budget cut it short. total_wait_ms lets it measure user-visible latency. A trace_id lets support reconcile the failure with logs. Include the idempotency key you used, so a later manual replay can be deduplicated rather than repeated Stripe idempotent requests.

Then pick a fallback, in order: another model or provider through the same interface, a cached or previously generated response, a degraded answer with a visible notice, or a hard failure. When you route across several providers, one OpenAI-compatible endpoint makes the fallback a configuration change rather than a rewrite, which is what Quanta Inference provides, covering text, image, video, audio and embedding models behind one key.

The metrics that tell you the policy works

Retry policies decay quietly. A budget that is never hit during testing can be saturated in production, and a cap that was right for one workload is wrong for another. Track these:

  • Retry rate per error class. If 429s dominate, the problem is volume and pacing, not backoff.
  • Budget exhaustion rate. The share of calls where the budget refused a retry. A steady rise means your global cap is too tight or your failure rate is too high.
  • Attempts per successful call. A mean above about 1.1 suggests the upstream is unhealthy or your base delay is too short.
  • Time to success distribution. Watch p95 and p99, not the mean. Retries inflate the tail long before they move the average.
  • Exhausted retries by endpoint. This is your list of calls that need a fallback path.

When a metric crosses a threshold, the response should be a policy change, not a manual intervention: raise the cap for a specific error class, lengthen the base delay, or add a fallback route. If your agents have grown complex enough that retry behavior differs per workflow, map the policy per endpoint and per model first, then decide whether the coordination work is worth doing in-house. Teams that would rather hand the build to a specialist can describe the system they need and have it built with them.

FAQ

FAQ

Should I retry a 429 that includes a Retry-After header?

Yes, but treat the header as a floor, not a suggestion to retry instantly. Sleep at least the stated time, then apply jitter around the remaining window so a fleet of clients does not all wake at the same second. If the 429 body carries a billing or spend code rather than a rate-limit code, do not retry at all.

How many attempts is too many?

For interactive agent calls, anything beyond five attempts is usually wasted effort: each one adds latency the user feels, and the upstream was likely already saturated. Six attempts at 30 seconds each would burn three minutes, which is why a wall-clock deadline should gate the loop before the attempt count does a TypeScript retry implementation.

Why is full jitter better than adding a small random offset?

A small offset still leaves retries clustered around the same instant. Full jitter spreads them uniformly across the entire backoff window, which produced the lowest server load in the AWS measurements AWS on exponential backoff and jitter. The trade-off is slightly longer average completion time, which rarely matters when the upstream is the constraint.

What if my call is not idempotent and I get a timeout?

You cannot safely retry, because the request may have succeeded and only the response was lost. Generate an idempotency key before the first attempt, persist it alongside the operation, and reuse it for every retry of that same operation; Stripe's v1 contract replays the original status and body when a duplicate key arrives Stripe idempotent requests. Without that key, reconcile the outcome before trying again.

Does a global retry budget risk dropping recoverable calls?

It does, and that is the intended trade. The budget protects the upstream so the majority of calls can succeed, at the cost of failing some calls that might have recovered on a fourth attempt. Set the budget as a rolling-window percentage, alert when it starts refusing retries, and give refused calls a fallback path so the user sees a degraded answer instead of an error AWS retry with backoff pattern.

Sources

Sources

  1. Webhook Retry Best Practices for Sending Webhooks
  2. AI API 429, 529 and 503 Errors: Retry Rules That Match the ...
  3. Advanced error handling | Stripe Documentation
  4. Idempotent requests | Stripe API Reference
  5. Exponential backoff | Svix Resources
  6. Exponential Backoff And Jitter | AWS Architecture Blog
  7. A 100-Line Retry-with-Exponential-Backoff and Jitter for ...
  8. Webhook Retries and Idempotency Keys - Best Digital Marketing Agency
  9. Retry behavior - AWS SDKs and Tools
  10. Retry with backoff pattern - AWS Prescriptive Guidance
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles