n4nAI

Why retry storms make rate limiting worse

Retry storms turn transient 429s into outages. Analyze how retry storm rate limiting amplifies throttling and the engineering controls that stop it.

n4n Team5 min read991 words

Audio narration

Coming soon — every post will get a voice note here.

A naive retry loop turns a transient 429 into a sustained outage. The interaction between retry storm rate limiting and backend throttles is counterintuitive: the more aggressively you retry, the more likely you are to trip limits that wouldn’t have been hit otherwise. This article breaks down why unbounded retries amplify congestion and what concrete controls stop it.

The mechanics of a retry storm

Rate limiters at LLM providers typically operate on fixed windows or token buckets per API key, sometimes per model. When you exceed the allocated quota, you get a 429 with a Retry-After header. The header tells you how long to wait, but many client SDKs ignore it or apply a fixed sleep.

What rate limiters actually count

A typical gateway counts requests or tokens per second. For LLM inference, the token count includes both prompt and completion tokens, so a retried 2k-token request burns double against the limit even if the first attempt produced no output. If your client sends 10 requests, gets 429 on the 11th, and immediately retries that 11th plus the next 10, you’ve doubled the offered load in the next window. The limiter doesn’t care that these are “retries”; they are new requests with new token weights.

Why retries synchronize

In a distributed system, many workers hit the same limit at the same time because they share a quota and similar request patterns. A cron job that wakes 500 lambdas at the top of the minute, or a serverless cold-start surge, produces naturally aligned traffic. Without jitter, they all sleep for the same backoff and wake up together, creating a periodic spike. This synchronization is the core of a retry storm rate limiting failure mode: the system oscillates between idle and overloaded instead of settling at a steady state.

A minimal reproduction

Consider a batch job that calls an embedding endpoint for 1,000 documents. The provider allows 100 req/s. You fire 200 concurrent requests.

The naive loop

import requests

def embed(text):
    for _ in range(5):
        r = requests.post("https://api.example.com/embed", json={"input": text})
        if r.status_code == 200:
            return r.json()
        # no wait, immediate retry
    raise RuntimeError("failed")

This code will issue up to 5 immediate retries per failed request. Under a 429, it multiplies load by 6x. The provider’s limiter sees a flood and may blacklist the key temporarily, turning a short throttle into a 10-minute ban.

Adding backoff without jitter is not enough

import time, requests

def embed(text):
    delay = 1
    for _ in range(5):
        r = requests.post("https://api.example.com/embed", json={"input": text})
        if r.status_code == 200:
            return r.json()
        if r.status_code == 429:
            time.sleep(delay)
            delay *= 2
            continue
        raise RuntimeError(r.text)
    raise RuntimeError("failed")

Exponential backoff reduces immediate pressure, but if 200 workers all start with 1s and double, they re-align every cycle. The retry storm rate limiting problem persists because the aggregate offer curve is still bursty. Worse, the backoff eats into your batch deadline, so late jobs pile up and retry again.

Client-side controls that actually work

You need to decouple retry timing from a shared clock and cap total offered load.

Token buckets per process

Implement a local token bucket that refills at the provider’s stated rate, and borrow from it before sending. This makes your client self-throttling and prevents a single misbehaving worker from exceeding its fair share.

import time

class TokenBucket:
    def __init__(self, rate, capacity):
        self.rate = rate
        self.capacity = capacity
        self.tokens = capacity
        self.last = time.monotonic()
    def consume(self, n=1):
        now = time.monotonic()
        self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate)
        self.last = now
        if self.tokens >= n:
            self.tokens -= n
            return True
        return False

Pair this with a hard cap on in-flight requests using a semaphore. If the bucket is empty, wait or drop. The bucket rate should be set below the provider limit by a safety margin (e.g., 80%) to absorb latency variance.

Jitter and deadline propagation

Always add full jitter to backoff: sleep = base * 2**attempt * random(). Propagate a request deadline so that a retry that cannot complete within SLA is abandoned instead of queued.

import random, time

def backoff(attempt, base=0.5):
    return base * (2 ** attempt) * random.random()

A deadline-aware caller:

def embed_with_deadline(text, deadline, attempt=0):
    if time.monotonic() > deadline:
        raise TimeoutError("deadline exceeded")
    r = requests.post("https://api.example.com/embed", json={"input": text})
    if r.status_code == 200:
        return r.json()
    if r.status_code == 429:
        time.sleep(backoff(attempt))
        return embed_with_deadline(text, deadline, attempt+1)
    raise RuntimeError(r.text)

This bounds the number of retries by time, not just count, and spreads wakeups. In async code, replace time.sleep with await asyncio.sleep and wrap the bucket in an async lock.

Gateway and provider interactions

When you route through an inference gateway, the dynamics change slightly. An inference gateway like n4n.ai can absorb some of this by automatic fallback when a provider is rate-limited or degraded, but that only helps if your client isn’t already flooding it. The gateway may hold a larger quota pool or route to a secondary provider, but your retries still consume gateway connections and token metering.

Where a fallback helps

If provider A returns 429, the gateway shifts the request to provider B. That prevents a single-provider throttle from blocking your job. However, if your client fires retries blindly, the gateway sees N times the original load and may hit its own limits. Use the gateway’s routing directives: set X-Route-Fallback: false for idempotent read calls if you handle fallback client-side, or rely on the gateway only after local backoff is exhausted.

n4n.ai honors client routing directives and forwards provider cache-control hints, and provides per-token usage metering so you can quantify exactly how many tokens your retries waste. That visibility turns retry tuning from guesswork into a measurable budget line.

Cache-control and routing hints

Providers support cache-control hints for prompt prefixes. Forwarding those via the gateway reduces repeated compute. If you retry the same prompt, a cache hit avoids the rate limit entirely. Honor Cache-Control: max-age=3600 in your request JSON where supported.

{
  "model": "example/model",
  "messages": [{"role": "user", "content": "static prefix ..."}],
  "cache_control": {"type": "ephemeral", "ttl": 3600}
}

A retried request with a cache hit costs far fewer tokens, which directly reduces the retry storm rate limiting pressure on the backend.

Tradeoffs: latency vs survival

Aggressive retries with no backoff give lowest latency when the system is healthy, but catastrophically worse latency when throttled. Self-throttling with token buckets adds a small constant delay per request but keeps p99 stable under congestion. Jitter trades a bit of idle time for desynchronization.

The cost of implementing these controls is modest: a few dozen lines of code and a config value for the rate. The cost of not implementing them is sporadic outages that correlate with your highest traffic, exactly when you can least afford them. In practice, teams that add client-side buckets see fewer pages and lower token waste within a week.

Decisive takeaway

Retry storm rate limiting is a self-inflicted denial of service. Treat provider 429s as signals to reduce offered load, not invitations to try harder. Implement a local token bucket, full jitter, and deadline caps; respect Retry-After; and let a gateway handle provider-level fallback only after your client is already well-behaved. Ship the bucket before you ship the retry.

Tagsretry-stormrate-limitsreliabilityanalysis

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All rate limits, retries & error handling posts →