Your payment API call failed at 2 a.m. Was it a network blip, a rate limit, or a dead server? The answer decides whether retrying saves the transaction or double-charges a customer.
Retries are the most common resilience pattern in distributed systems, and the most commonly botched. A loop wrapped around an HTTP call feels like defensive programming. Done wrong, it turns a 30-second outage into a 30-minute one, because thousands of clients hammer a struggling server at the same moment. Done right, retries absorb transient failures so cleanly your users never notice them.
This guide covers the retry logic production systems depend on: which status codes to retry, the exponential backoff formula with full jitter, Retry-After headers, idempotency keys, retry budgets, and circuit breakers. You’ll also see how to prove your client behaves correctly by simulating 429s and 503s with Apidog mock servers, because a retry pattern you’ve never tested against a failing server is a guess, not a design. Teams building fintech API retry logic learn this the expensive way; you don’t have to.
Why naive retries make outages worse
Picture a service handling 1,000 requests per second. It hiccups for five seconds. Every client retries immediately, three times each. Your 1,000 rps of demand becomes 4,000 rps aimed at a server already on its knees. It falls over completely. Now every client retries again.
That feedback loop has a name: a retry storm. The synchronized stampede when the server comes back is the thundering herd. Google’s SRE book calls this pattern out in its chapter on addressing cascading failures: retries without backoff amplify load exactly when the system can least afford it, and can keep a service down long after the original fault is fixed.
Two design flaws cause most retry storms:
- No delay between tries. Immediate retries multiply load during the worst possible window.
- Fixed delays. If every client waits exactly one second, they all come back in lockstep. The server gets waves of synchronized traffic instead of a smooth ramp.
The fix is not “never retry.” The fix is retrying selectively, with growing randomized delays, and with a hard ceiling on how much extra load your retries add.
Retry these failures, never those
Before any backoff math, your client needs a decision table. Retrying a request the server has already rejected as invalid wastes capacity and pollutes logs. Retrying a transient fault is the whole point.
Retry these:
| Signal | Meaning |
|---|---|
| 429 Too Many Requests | You hit a rate limit. Back off and come back slower. |
| 502 Bad Gateway | An upstream hop returned garbage. Often transient. |
| 503 Service Unavailable | The server is overloaded or restarting. |
| 504 Gateway Timeout | An upstream dependency was too slow. |
| Connection resets, DNS failures, socket timeouts | The request may never have arrived. |
A 504 gateway timeout deserves special care: the origin may have processed your request even though the gateway gave up waiting. That distinction matters once we get to idempotency.
Never retry these:
| Signal | Meaning |
|---|---|
| 400 Bad Request | Your payload is malformed. It will be malformed next time too. |
| 401 Unauthorized | Your credentials are wrong or expired. Refresh the token, don’t loop. |
| 403 Forbidden | You lack permission. Retrying won’t grant it. |
| 422 Unprocessable Entity | Validation failed. Fix the data, not the timing. |
The rule: retry when the failure is about the server’s state or the network. Fail fast when the failure is about your request. A 429 sits in between; it’s retryable, but it’s also a signal your overall request rate needs work, which is a rate limiting problem to solve upstream of any retry loop.
The exponential backoff formula, and why jitter matters
Exponential backoff means each retry waits longer than the last, doubling by default:
delay = base * 2^retry_count
With a base of 500 ms, that’s 0.5s, 1s, 2s, 4s, 8s. Add a cap (say 30 seconds) so delays don’t grow into minutes:
delay = min(cap, base * 2^retry_count)
This solves the “hammering” problem but not the synchronization problem. If 5,000 clients fail at the same instant, plain exponential backoff has all 5,000 coming back at t=0.5s, then t=1s, then t=2s. Still waves. Still a herd, only a politer one.
Jitter breaks the synchronization by randomizing the delay. The AWS Architecture Blog ran the numbers in its exponential backoff and jitter analysis, simulating competing clients against a contended resource. Backoff without jitter still produced clustered spikes of calls. Full jitter, which picks a random delay between zero and the exponential ceiling, produced both the fewest total calls and close to the shortest completion times:
delay = random_between(0, min(cap, base * 2^retry_count))
That result surprises people. Randomizing all the way down to zero feels sloppy compared to a tidy doubling schedule. But spreading clients uniformly across the window is exactly what keeps server load flat. The AWS analysis also tested “equal jitter” (half fixed, half random) and “decorrelated jitter”; full jitter and decorrelated jitter came out ahead, and full jitter is the simplest to write correctly. Use it as your default retry pattern unless you have measurements saying otherwise.
Honor Retry-After when the server tells you
Backoff is your client guessing how long to wait. Sometimes the server removes the guesswork. The Retry-After header, defined for 429 and 503 responses, carries either a number of seconds or an HTTP date:
HTTP/1.1 429 Too Many Requests
Retry-After: 12
When this header is present, it overrides your computed backoff. The server knows when its rate-limit window resets or its maintenance ends; your exponential schedule doesn’t. Clients ignoring Retry-After is one reason providers escalate from throttling to outright bans. Parse it, respect it, and still apply your cap and max-retry count so a hostile or buggy Retry-After: 86400 can’t hang your worker for a day.
Idempotency: the precondition for retrying POST
Here’s the trap in that 504 from earlier. GET, PUT, and DELETE are idempotent by contract: sending them twice leaves the system in the same state. POST is not. If POST /v1/payments times out after the server processed it, your retry creates a second payment. Congratulations, you’ve built a double-charging machine with excellent uptime.
The fix is an idempotency key: a unique client-generated ID (usually a UUID) sent as a header on each logical operation. The server stores the key with the first response and replays that stored response for any duplicate. Stripe’s idempotent requests work exactly this way, and most payment and provisioning APIs have followed.
Two rules make keys work:
- Same operation, same key. Every retry of one logical payment reuses one key. A new user action gets a fresh key.
- Generate the key before the first send, not inside the retry loop. Otherwise each retry looks like a new operation and the protection evaporates.
If the API you’re calling doesn’t support idempotency keys, don’t retry non-idempotent writes automatically. Surface the failure and let a human or a reconciliation job decide.
Retry budgets and circuit breakers: the escape hatch
Backoff shapes when retries happen. It doesn’t limit how many happen. During a long outage, even well-jittered clients pile up retry load, and layered retries multiply: if your API gateway retries 3 times and your service client retries 3 times, one user click can become 9 requests.
Two mechanisms cap the damage:
Retry budgets. Instead of “3 retries per request,” enforce “retries may add at most 10% extra load,” measured over a sliding window. When the budget is spent, failures return immediately. This keeps retry amplification bounded no matter how many requests are failing at once. Linkerd and Envoy both ship this as a first-class config.
Circuit breakers. Track the failure rate per downstream. When it crosses a threshold, the breaker opens: calls fail instantly without touching the network. After a cooldown, a few probe requests test whether the dependency recovered before the breaker closes again. Where backoff politely slows the stampede, the breaker cancels it. Every serious retry design pairs the two, because backoff alone still sends every request eventually.
A production-ready example in Python
Here’s the whole pattern in one place: retryable-status filtering, full jitter, Retry-After support, an idempotency key, and a hard retry cap.
import random
import time
import uuid
import requests
RETRYABLE = {429, 502, 503, 504}
BASE = 0.5 # seconds
CAP = 30.0 # ceiling on any single delay
MAX_RETRIES = 5
def create_payment(payload):
idempotency_key = str(uuid.uuid4()) # one key per logical payment
headers = {"Idempotency-Key": idempotency_key}
for retry_count in range(MAX_RETRIES + 1):
try:
resp = requests.post(
"https://api.acmepay.com/v1/payments",
json=payload, headers=headers, timeout=10,
)
if resp.status_code < 400:
return resp.json()
if resp.status_code not in RETRYABLE:
resp.raise_for_status() # 400/401/403/422: fail fast
retry_after = resp.headers.get("Retry-After")
except (requests.ConnectionError, requests.Timeout):
retry_after = None # network fault: fall through to backoff
if retry_count == MAX_RETRIES:
raise RuntimeError("payment failed after all retries")
if retry_after and retry_after.isdigit():
delay = min(CAP, float(retry_after))
else:
delay = random.uniform(0, min(CAP, BASE * 2 ** retry_count))
time.sleep(delay)
Worth noticing: the key is minted once, outside the loop. Retry-After wins over computed backoff but still respects the cap. Non-retryable statuses raise immediately. If you’re on the JavaScript side, the axios-retry library gives you the same shape with retryCondition and retryDelay hooks; the decision table stays identical.
How to test retry behavior before production does it for you
Most teams ship retry code that has never once executed its failure branch. The happy path got tested; the 503 path runs for the first time during a real outage. You can do better with two Apidog features.
Simulate failures with mock servers. Apidog’s smart mock lets you define an endpoint like /v1/payments and script its responses. Make it return 503 for the first two calls and 200 on the third, or return a 429 with Retry-After: 5, or add a 15-second delay to trigger your client timeout. Point your client at the mock URL and watch the retry loop handle each scenario, no production incident required.
Assert client behavior with test scenarios. Apidog test scenarios chain requests with assertions and timing checks. Build a scenario that fires against your flaky mock and asserts the call eventually succeeds, total elapsed time falls inside your expected backoff envelope, and exactly one resource was created (proving your idempotency key did its job). Wire the scenario into CI and your retry logic gets exercised on every commit instead of every outage.
This is the difference between “we added retries” and “we verified our client survives a rate-limited, half-down dependency.” Download Apidog free and you can have a failing mock server running against your client in about ten minutes.
FAQ
Should I retry a 429?
Yes, and it’s the one status where the server usually tells you how. Read the Retry-After header and wait at least that long; fall back to exponential backoff with jitter if the header is missing. Also treat repeated 429s as a signal to fix your request rate with client-side throttling or caching, not as normal operation.
What is full jitter?
Full jitter picks each retry delay uniformly at random between zero and the exponential ceiling: random(0, min(cap, base * 2^n)). It prevents synchronized retry waves from many clients. In AWS’s simulations it beat plain backoff and equal jitter on both total calls made and time to completion, which is why it’s the default in the AWS SDKs.
Is it safe to retry POST requests?
Only when the request is idempotent in practice, which for POST means sending an idempotency key the server deduplicates on. Without one, a retry after a timeout can duplicate a payment, order, or record, because the server may have processed the request you think failed. AI agents calling write APIs hit this constantly; the agent error recovery patterns are the same ones covered here: keyed writes, capped retries, and a circuit breaker.
How many times should I retry?
Three to five tries handles almost every transient fault; beyond that, success rates flatten while load and latency keep climbing. Pair the per-request cap with a global retry budget (for example, retries may add 10% extra traffic) so a full outage can’t multiply your load. If a dependency stays down past your last retry, that’s circuit-breaker territory, not retry territory.



