Workflow automation

Rate limits, retries and backoff, explained properly

Most retry code makes outages worse. It retries things that can never succeed, ignores the header telling it when to come back, and synchronises every client in the fleet onto the same second.

On this page
  1. The short answer: classify, obey the server, jitter, then stop
  2. The four limiter shapes, and why they feel different
  3. Read the response before you decide to retry
  4. The Clock Stack, the five timers that govern a retry
  5. Jitter, and the storm you cause without it
  6. The client that behaves
  7. Concurrency, the limit nobody counts
  8. When you are the one being throttled every day

The short answer: classify, obey the server, jitter, then stop

The short answer

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.

LimiterHow it countsHow it feels from outsideWhat the client should do
Token bucketA bucket refills at a steady rate and each request spends a tokenA generous burst, then a hard stop at the refill ratePace to the refill rate and keep a few tokens in reserve for latency-sensitive calls
Fixed windowA counter that resets on the clock, often at the top of the minuteTwo full bursts back to back across the boundary, then a wallNever align your schedule to the minute or hour boundary, and expect a cliff at the reset
Sliding windowRequests counted continuously over the trailing periodSmooth refusal with no boundary spikePace evenly, because saving requests up buys you nothing here
Concurrency limitIn-flight requests, not requests per secondFine at any rate until a slow endpoint appears, then instant refusalsCap your own parallelism with a semaphore, since backoff does not help with this one
Identify the shape from the behaviour, then pace accordingly.
The terms, used exactly
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.

ResponseRetryHow long to waitThe trap
429 with Retry-AfterYesExactly the value in the headerSubstituting your own backoff, which usually returns early and extends the throttle
429 with no headerYesExponential with jitter, cappedRetrying immediately because the refusal itself arrived immediately
502, 503, 504YesExponential with jitterAssuming the write did not land. A 504 frequently means it did
500Once or twiceBriefly, then dead letterHammering a broken server and turning a partial outage into a complete one
408, read timeout, connection resetOnly if the write is keyedExponential with jitterRetrying a non-idempotent write, which is precisely how duplicates get made
401, 403No, refresh the credential onceNot applicableA retry loop that converts an expired token into a locked account
400, 422NeverNot applicableSpending quota on a request that is malformed and will stay malformed
409 conflictNo, verify insteadNot applicableTreating a conflict as transient when it usually means your earlier write already succeeded
Retry only where a later attempt could change the answer.
Retry-After comes in two formats

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

Framework

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.

01
The server's clock overrides everything

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.

02
The caller's deadline

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.

03
The ladder budget

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.

04
The per-attempt timeout, in two halves

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.

05
The fleet retry budget

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.

t plus 0s
The dependency slows down

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.

t plus 2s
Attempt two, all at once

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.

t plus 6s
The ladder compounds

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.

t plus 30s
Recovery makes it worse

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.

With jitter
The same fault, spread out

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.

Retry amplification, on your numbers

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.

0Worst case delay added to one operation, seconds
0Requests your fleet sends during the outage
0Average extra requests per second you add
Retries multiply through every layer

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.pypython
# 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.

  1. Stop polling and start listeningBiggest saving

    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.

  2. Move to the batch endpointOne afternoon

    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.

  3. Put a queue in front of the integrationOne or two days

    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.

  4. Ask for more, with evidenceFree, often works

    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.

Retry policy review
0 of 10 done
Cite this

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?
Honour the Retry-After header if one is present, parsing both the seconds form and the HTTP date form. If there is no header, back off exponentially with jitter from a small base, cap the individual wait, and cap the total elapsed time across the ladder. Retrying sooner than the server suggested usually extends the throttle rather than shortening it.
How many retries should I configure?
Three to five for transient errors, though the attempt count is the wrong control. Set a total elapsed budget the calling system can absorb and let the number of attempts follow from it. Five attempts sitting behind a thirty second read timeout can stall a caller for over two minutes, which is rarely what anyone intended when they chose five.
What is jitter and why does it matter?
Jitter is randomness added to each retry delay so clients that failed together do not return together. Without it, a fleet retries in unison and creates a load spike on a dependency that is already struggling, which frequently keeps it down. Full jitter waits a random time between zero and the current backoff ceiling and costs one line of code.
Should I retry a POST request?
Only if the operation carries an idempotency key or is otherwise safe to repeat. A timeout on a POST is ambiguous: the write may well have succeeded and the response was lost. Without a key, retrying gambles on that ambiguity. With a key, the second attempt is deduplicated by the server or by your own claim table and the ambiguity disappears.
What is a retry budget?
A ceiling on retries expressed as a share of total requests across your whole fleet rather than per call. When retries exceed the share, typically around ten percent, the client stops retrying and fails fast until the share recovers. It stops callers from keeping a recovering dependency down, and it is the control most retry implementations leave out.
How do I stop hitting a rate limit in the first place?
Reduce the requests rather than tuning the recovery. Replace polling with webhooks, use batch endpoints instead of per-record loops, cache anything fetched twice in one run, and pace outbound calls through a client-side token bucket set slightly under the published limit. Then ask the provider for a higher limit with your measured pattern in hand.
Cite this

ChatGPTalker. "Rate Limits, Retries and Backoff, Explained Properly." chatgptalker.com, 2026-08-26. https://chatgptalker.com/guides/rate-limits-retries-backoff/

Rather have it built than read about it?

Send the process you want automated. You get a scoped plan back, with the build shape, the stack and a realistic timeline.

Start a project