On this page
- The short answer: classify, obey the server, jitter, then stop
- The four limiter shapes, and why they feel different
- Read the response before you decide to retry
- The Clock Stack, the five timers that govern a retry
- Jitter, and the storm you cause without it
- The client that behaves
- Concurrency, the limit nobody counts
- When you are the one being throttled every day
The short answer: classify, obey the server, jitter, then stop
Retry only errors that a later attempt could plausibly fix, which means timeouts, 429s and most 5xx responses, and never 400, 401, 403, 404 or 422. When the response carries a Retry-After header, use that value instead of your own backoff. Add randomness to every wait, because fixed backoff synchronises your whole fleet onto the same second and turns a brief throttle into a self-inflicted outage. Budget the total elapsed time of the ladder rather than counting attempts, and when the budget runs out, send the item to a dead letter store rather than looping again.
- Retry-AfterThe server telling you exactly when to return. Honouring it beats every backoff algorithm you could write.
- JitterWithout randomness, every client that failed together retries together. The retry, not the original fault, becomes the outage.
- TimeBudget total elapsed seconds, not attempt count. Five attempts behind a thirty second timeout is a two and a half minute stall nobody signed off.
- KeyedRetrying a write with no idempotency key is not resilience. It is a duplicate generator with good intentions.
Retry logic is the most copied and least examined code in any integration. It gets written once during an incident, it looks reasonable, and it stays wrong for years because the conditions that expose it are rare and brief. The failure is rarely the algorithm. It is retrying the wrong class of error, ignoring what the server said, and having no ceiling on the damage a retrying fleet can do.
The four limiter shapes, and why they feel different
A rate limit is not one mechanism. Which one you are behind changes the correct client behaviour completely, and you can usually identify it from how refusals arrive rather than from the documentation.
| Limiter | How it counts | How it feels from outside | What the client should do |
|---|---|---|---|
| Token bucket | A bucket refills at a steady rate and each request spends a token | A generous burst, then a hard stop at the refill rate | Pace to the refill rate and keep a few tokens in reserve for latency-sensitive calls |
| Fixed window | A counter that resets on the clock, often at the top of the minute | Two full bursts back to back across the boundary, then a wall | Never align your schedule to the minute or hour boundary, and expect a cliff at the reset |
| Sliding window | Requests counted continuously over the trailing period | Smooth refusal with no boundary spike | Pace evenly, because saving requests up buys you nothing here |
| Concurrency limit | In-flight requests, not requests per second | Fine at any rate until a slow endpoint appears, then instant refusals | Cap your own parallelism with a semaphore, since backoff does not help with this one |
- Rate limit
- A server-side rule capping how much work one caller may request in a period. It exists to protect the provider's capacity and its other customers, so treating it as an obstacle to route around is how an account gets suspended.
- Exponential backoff
- A retry schedule where each wait is multiplied by a constant factor, typically doubling. It reduces pressure on a struggling service, and on its own it still synchronises clients that failed at the same moment.
- Jitter
- Deliberate randomness added to a retry delay so that clients which failed together do not return together. Full jitter picks uniformly between zero and the current backoff ceiling. It is the single most valuable line in any retry implementation.
- Retry budget
- A fleet-wide ceiling on retries expressed as a share of total requests. When retries exceed the share, the client stops retrying entirely and sheds load, which stops a struggling dependency being kept down by its own callers.
- Thundering herd
- The load spike created when many clients retry in unison after a shared failure. It is caused by the recovery logic rather than the original fault, and it is why a service can come back up and immediately fall over again.
Read the response before you decide to retry
The status code tells you whether a retry can possibly help. Getting this table right removes more failed runs than any tuning of delays, because most wasted retries are spent on requests that were never going to succeed.
| Response | Retry | How long to wait | The trap |
|---|---|---|---|
| 429 with Retry-After | Yes | Exactly the value in the header | Substituting your own backoff, which usually returns early and extends the throttle |
| 429 with no header | Yes | Exponential with jitter, capped | Retrying immediately because the refusal itself arrived immediately |
| 502, 503, 504 | Yes | Exponential with jitter | Assuming the write did not land. A 504 frequently means it did |
| 500 | Once or twice | Briefly, then dead letter | Hammering a broken server and turning a partial outage into a complete one |
| 408, read timeout, connection reset | Only if the write is keyed | Exponential with jitter | Retrying a non-idempotent write, which is precisely how duplicates get made |
| 401, 403 | No, refresh the credential once | Not applicable | A retry loop that converts an expired token into a locked account |
| 400, 422 | Never | Not applicable | Spending quota on a request that is malformed and will stay malformed |
| 409 conflict | No, verify instead | Not applicable | Treating a conflict as transient when it usually means your earlier write already succeeded |
It is either a number of seconds or an HTTP date, and clients that parse only the number silently fall back to their own backoff against the servers that use the date form. Parse both. While you are there, log the header value on every 429, because a rising Retry-After across a run is the clearest early signal that you are pushing a provider harder than it wants.
The Clock Stack, the five timers that govern a retry
The Clock Stack
Every retry decision is governed by five clocks, and most implementations set one of them. Setting all five is what separates retry code that protects a system from retry code that amplifies its failures. Work down the stack: each clock overrides the ones below it.
If the response carries Retry-After, that value wins over your algorithm, your cap and your impatience. The provider knows when capacity returns and you are guessing. Returning early is what converts a short throttle into a long one, because early returns count against the same limit you are waiting on.
Whoever invoked this work has a limit: an HTTP request that will time out, a queue lease that will expire, a person watching a spinner. Propagate that deadline into the call and never begin an attempt that cannot finish inside it. A retry completing after the caller has given up is pure cost with no beneficiary.
Cap the total elapsed time across all attempts, not the attempt count. Five attempts with doubling backoff behind a thirty second read timeout is over two minutes of stall, which nobody agreed to when they wrote the number five. Set the budget to a value the calling system can actually absorb, then let the attempt count fall out of it.
Connect timeout and read timeout are separate problems. A short connect timeout catches a dead host in seconds. A read timeout must exceed the slowest legitimate response or you will abandon and retry work the server is still completing, which doubles the load exactly when it is struggling.
Track retries as a share of total requests across all your workers. If retries exceed something like ten percent, stop retrying entirely and fail fast until the share recovers. This is the clock almost nobody sets, and it is the one that stops a dependency being held down by its own callers after it tries to come back.
Jitter, and the storm you cause without it
Fixed backoff does not spread load, it schedules it. Two hundred workers that all failed at the same instant will all wait exactly two seconds, then all retry in the same tenth of a second. The service that was briefly unwell is now being load tested by its own clients, on a two second cycle, until something gives.
Every in-flight request across the fleet times out or returns 503 within the same short window, so every client enters its retry ladder at the same moment.
With fixed backoff, all of them return together. The dependency, which was just starting to drain its queue, receives its full fleet load in one burst and slows further.
Each client is now on its third attempt while the first attempts are still queued upstream. Requests in flight exceed the original traffic level, and none of the extra requests represent new work.
The dependency recovers briefly, admits a burst, exhausts its connection pool and falls over again. From its own logs this looks like a traffic spike rather than a retry storm, which is why the wrong fix gets applied.
Randomising each delay between zero and the current ceiling spreads the same attempts across the whole interval. Total work is unchanged, peak load drops sharply, and the dependency drains instead of drowning.
Two randomisation schemes are worth knowing. Full jitter waits a uniform random time between zero and the current exponential ceiling, which is simple and effective. Decorrelated jitter uses the previous delay to set the next upper bound, which spreads a long ladder more evenly and is the one in the code below. Either beats a fixed sequence by a wide margin. Neither has a downside worth discussing.
Enter the shape of your own ladder. The point of the arithmetic is the request count your fleet generates during a short outage, which is the number that decides whether you help or hurt.
Three services chained together, each retrying three times, sends up to twenty seven requests to the system at the bottom for one user action. Retry at the edge closest to the failure and pass failures upward, or set the inner layers to a single attempt. Nested retry ladders are the most common cause of a small dependency taking a whole platform down with it.
The client that behaves
This is the whole pattern in one function: classification before anything else, the server's header preferred over local backoff, decorrelated jitter on every wait, a total time budget, and an idempotency key so that a retry after a lost response cannot duplicate a write.
# retry.py
# Four rules: classify first, obey the server, jitter always, budget the whole ladder.
import datetime as dt
import random
import time
from email.utils import parsedate_to_datetime
RETRYABLE = {408, 425, 429, 500, 502, 503, 504}
TERMINAL = {400, 401, 403, 404, 409, 422}
class Terminal(Exception): ... # a retry cannot change this answer
class Exhausted(Exception): ... # send it to the dead letter store, not round again
def retry_after_seconds(resp, fallback):
"""A server that tells you when to come back has ended the argument."""
v = resp.headers.get("Retry-After")
if not v:
return fallback
try:
return float(v) # delta-seconds form
except ValueError:
when = parsedate_to_datetime(v) # HTTP-date form
return max(0.0, (when - dt.datetime.now(dt.timezone.utc)).total_seconds())
def call(session, method, url, *, idempotency_key,
attempts=5, base=0.5, cap=60.0, budget=120.0, **kw):
kw.setdefault("headers", {})["Idempotency-Key"] = idempotency_key
sleep_prev = base
started = time.monotonic()
for attempt in range(1, attempts + 1):
# connect timeout and read timeout are different problems, so set them apart
resp = session.request(method, url, timeout=(3.05, 27), **kw)
if resp.status_code in TERMINAL:
raise Terminal(resp.status_code, resp.text[:500])
if resp.status_code not in RETRYABLE:
return resp # success, or yours to handle
# decorrelated jitter: spreads a fleet out instead of synchronising it
sleep_prev = min(cap, random.uniform(base, sleep_prev * 3))
delay = retry_after_seconds(resp, sleep_prev)
spent = time.monotonic() - started
if attempt == attempts or spent + delay > budget:
raise Exhausted(resp.status_code, attempt, round(spent, 1))
time.sleep(delay)Three details in there matter more than they look. The split timeout tuple stops a slow read being treated like a dead host. The elapsed check before sleeping prevents a final wait that overshoots the budget by a minute. And the idempotency key is not optional decoration: without it, every retry on a write is a coin flip, which is the subject of idempotency in automation.
One thing the function deliberately does not do is retry inside a client library that is already retrying. Most HTTP libraries and SDKs ship with retry behaviour enabled by default. Turn it off when you own the loop, or your five attempts quietly become fifteen.
Concurrency, the limit nobody counts
Requests per second is the limit people design for. In-flight requests is the limit that actually bites, because it depends on the provider's latency rather than on your intent. When the endpoint slows down, your parallelism climbs on its own and the limiter refuses you without your request rate changing at all.
- Cap your own parallelism with a semaphore and set the value deliberately. Ten workers hitting an endpoint that normally answers in 200ms behave completely differently when it starts answering in 8 seconds.
- Pace your requests with a client-side token bucket rather than waiting to be refused. Being refused costs a round trip, counts against some limiters, and gives you no information you did not already have.
- Give bulk work its own limiter, separate from interactive work. A backfill should never be able to consume the quota that customer-facing calls depend on.
- Prefer batch endpoints where they exist. One request carrying fifty records almost always costs one unit against the limit rather than fifty.
- Cache anything you fetch more than once per run. The cheapest way to stay under a limit is to stop making requests you already made.
Where a provider publishes its limits, write them into a config file next to the workflow rather than into the retry code, and check them again at each integration review. Limits change with plan tiers and with vendor policy, so a number hardcoded eighteen months ago is a number nobody is checking. This belongs in the same place as the rest of the integration contract during systems integration work.
When you are the one being throttled every day
Persistent throttling is a design problem wearing an incident costume. If you hit the limit most days, no retry policy will fix it, because the work you are asking for genuinely exceeds what you are permitted. Four moves in order of effort.
- Stop polling and start listening
Polling every minute for changes that happen twice a day spends almost all of your quota confirming nothing changed. A webhook removes those requests entirely. The trade is covered in webhooks versus polling.
- Move to the batch endpoint
Rewrite per-record loops as batch calls where the provider offers them. This is usually the largest single reduction available and it also removes the fan-out that most workflow platforms meter per item.
- Put a queue in front of the integration
Give every outbound call to that provider a single queue with a fixed drain rate set slightly below the limit. Bursts become latency instead of errors, and you get one place to change the rate rather than a rate hidden inside every workflow.
- Ask for more, with evidence
Providers raise limits for callers who can describe their pattern, show they honour Retry-After, and explain what the traffic does. Arriving with a measured request pattern gets a different answer from arriving with a complaint.
ChatGPTalker, Rate Limits, Retries and Backoff, Explained Properly: retry only errors a later attempt could fix, prefer the server's Retry-After header, randomise every wait, and budget the ladder in elapsed time rather than attempts.
Questions readers ask next
What is the correct retry strategy for a 429 response?
How many retries should I configure?
What is jitter and why does it matter?
Should I retry a POST request?
What is a retry budget?
How do I stop hitting a rate limit in the first place?
ChatGPTalker. "Rate Limits, Retries and Backoff, Explained Properly." chatgptalker.com, 2026-08-26. https://chatgptalker.com/guides/rate-limits-retries-backoff/