CachingAugust 28, 202613 min read

429 Too Many Requests: Rate Limiting from Both Sides

You are usually on both ends of this at once — limited by somebody's API and limiting your own. One header joins the two sides, and almost nobody sends or reads it properly.

RThe Runsite Team

Search for this status code and Google will tell you to clear your browser cache, disable your extensions, deactivate WordPress plugins one at a time, and switch networks or use a VPN to change your IP address. That is genuinely the advice in the generated answer at the top of the results as of writing, and for someone who cannot load a shopping site it is the right advice.

It is not the advice you need if you write the software. In the eleven items on that first page, exactly one is aimed at a developer: a Stack Overflow question from twelve years ago, from which Google highlights four words that are the best summary of the whole topic.

This is written for the developer end of it, and specifically for the situation almost everyone is actually in — being rate limited by an API you depend on while rate limiting the API you operate. They are the same problem seen from two ends, and one HTTP header joins them.

429 is not an error

That is the phrase Google pulls out of that Stack Overflow answer, and it is worth taking literally. The answer continues: "1) Sleep your process. 2) Exponential backoff. If the server does not tell you how long to wait, you can retry...".

A 429 is the server successfully telling you something. Nothing failed: your request was understood, it was well-formed, you were authenticated, and the answer is that you are going faster than the agreement allows. Compare it with a 500, which means something broke and retrying might work, or a 403, which means stop and do not come back. A 429 means come back later, and it usually says how much later.

The distinction matters because of what a client does next. Code that treats 429 as a failure typically does one of two things, and both make it worse. It retries immediately, adding load to a server that just asked for less. Or it raises an exception into a queue of pending work, so the retry storm arrives all at once when someone restarts the process. Treating it as a control signal instead means reading what the signal says.

Retry-After, and what to do when it is missing

The header exists for exactly this. A well-behaved limiter answers like this:

http
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 30
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 30

{"error":"rate_limit_exceeded","detail":"100 requests per minute per API key"}

Retry-After takes two forms and clients need to handle both. It is either a number of seconds to wait, as above, or an HTTP-date giving the moment the limit lifts. The date form is the one that breaks naive parsers, and it is the one a limiter that resets on a fixed clock boundary will usually send.

The RateLimit-* family is the newer half of that response and the useful part for a well-written client, because it can slow down before hitting the wall rather than after. These headers are the subject of an IETF draft as of writing, so the exact names are worth checking against your dependencies; the X-RateLimit-* spellings remain widespread as the de facto predecessor and plenty of APIs still send those instead.

Exponential backoff with jitter, and why the jitter is the important half

When there is no Retry-After, you back off. Doubling the wait after each failure is the well-known part and it is not sufficient on its own, because every client that started at the same time doubles on the same schedule and they all come back together. The randomness is what breaks up the convoy.

python
# Wait for what the server asked for; back off with jitter when it did not say.
delay = response.headers.get("Retry-After")

if delay is not None:
    wait_seconds = parse_retry_after(delay)   # seconds, or an HTTP-date
else:
    base = min(MAX_BACKOFF, INITIAL_BACKOFF * 2 ** attempt)
    wait_seconds = random.uniform(0, base)    # full jitter, not base + jitter

Two details that get skipped. A cap on the backoff keeps a long outage from producing a retry scheduled an hour out, which is usually worse than failing cleanly. And a limit on attempts matters more than the delay curve: work that will never succeed should end up somewhere a human can see rather than being retried until the process restarts.

When you are the one being limited

Look at what people actually search alongside this status code and it is a directory of other people's products: Shopify, HubSpot, GitLab, Steam, Docker, Spotify, DeepL, Okta, Etsy, npm, Jira, Discord, Deutsche Bahn. The 429 you are reading is nearly always somebody else's decision about you, delivered after the fact.

That shapes the response. You cannot negotiate with it in code, so the work is to (a) find the documented limit before you meet it, (b) make the limit visible in your own monitoring rather than discovering it in a support ticket, and (c) decide which calls are allowed to fail and which have to be retried later, which is a queue question rather than a client question.

429 Too Many Requests OpenAI

That is a real related search on this results page, sitting next to 429 too many requests chatgpt and 429 too many requests gemini. Model APIs are now one of the most common sources of this status code, and they behave differently enough from a normal REST quota to be worth separating.

As of writing, these limits are typically two-dimensional: a cap on requests per minute and a separate cap on tokens per minute, and you can be nowhere near the first while sitting on the second. A long prompt consumes quota that a short one does not, so the same number of calls can pass in the morning and fail in the afternoon because the inputs got bigger. Retrying a second later achieves nothing when the window is a minute wide and your own traffic is what filled it.

The structural answer is that model calls belong behind a queue rather than in a request handler. The work is slow, the quota is shared, and the failure is temporary, which is the exact profile the worker and queue shape exists for. A user waiting on a synchronous call gets an error; a job in a queue gets retried in a minute and nobody notices.

When you are the one limiting

The other end. Three algorithms cover almost everything, and they differ mainly in what they do at the boundary of the window.

AlgorithmWhat it storesBehaviour at the boundaryWhen to pick it
Fixed windowOne counter per key per windowAllows a double burst: the full quota at the end of one window and again at the start of the nextCheap, simple, fine when the limit is a courtesy rather than a protection
Sliding windowA counter per sub-window, or timestampsSmooth, no double burst, at the cost of more memory and more work per requestPublic APIs where the boundary burst is the abuse case
Token bucket / GCRAA token count and a last-refill timestampAllows a deliberate burst up to bucket size, then enforces a steady rateInteractive traffic, where a short burst is normal user behaviour
Leaky bucket and GCRA are the same family: the go-redis/redis_rate package describes itself as implementing "GCRA (aka leaky bucket)". The bucket size is the part worth thinking about, because it is the burst you are explicitly allowing.

Whichever you pick, send the headers from the first section. A limiter that returns 429 with no Retry-After is asking every client to guess, and the guesses will be wrong in the direction that costs you: too short, and the retry storm continues; too long, and legitimate users are locked out of a limit that lifted a minute ago.

Don't use Redis as a rate limiter

That is the title of a Hacker News thread from August 2025 which currently sits in the top ten for redis rate limiting, three positions below Redis's own documentation on how to do it. A Medium series called "Why you shouldn't use Redis as a rate limiter" ranks between them. The official guidance and the argument against it are on the same page of results, and nobody has written the piece that resolves it.

The strongest version of the objection

From the Hacker News thread, verbatim: "Rate-Limiting is always a deployment specific feature. If it's for limiting user requests, then it should be a component of the ingress/API...". The point is that a limiter which only triggers after a request has reached your application has already cost you the connection, the routing and a round trip to Redis, which is the wrong place to absorb an attack.

That is correct, and it is an argument about one layer rather than about Redis. Rate limiting happens at more than one level, and the levels want different tools.

LevelWhat it can seeWhat it is good atWhat it cannot do
CDN or edgeIP, path, headersAbsorbing floods before they cost you anythingKnowing who the user is, or what their plan entitles them to
Ingress or reverse proxyIP, path, sometimes an API keyCheap coarse limits close to the doorPer-tenant quotas that depend on your database
ApplicationThe authenticated user, the tenant, the plan, the cost of the callExactly the limits you sell and the ones you owe an upstreamStopping traffic before it arrives
The objection is right about the first two rows and does not apply to the third. Coarse limits belong at the edge because they are cheap there; precise ones belong in the application because only the application knows what the numbers mean.

Redis belongs to the third row, and the reason is narrow: the counter has to be shared. Your application runs as more than one instance, and a per-process counter gives each instance its own allowance, so a limit of 100 per minute silently becomes 100 times however many instances you happen to be running. An in-memory counter is not a rate limiter for anything horizontally scaled; it is a per-instance suggestion.

The counter is durable data in a keyspace you treat as a cache

This is the failure worth planning for and it is quiet. Rate-limit counters usually live in the same Redis instance as the cache, because that is the Redis that already exists. They are not cache: a cache entry can be recomputed and a counter cannot. If that instance is configured with an allkeys-lru policy and memory gets tight, the counters are eligible for eviction along with everything else, and evicted counters mean the limit stops applying without a single error anywhere.

The general form of that trap, along with what the eight eviction policies actually do and why a mixed instance has no correct one, is in the write-up of the OOM error this produces at the other extreme. The short version for a limiter: counters want a TTL and an instance whose policy will not delete them, and deciding what belongs in Redis at all is the version of this question asked early.

Your rate limiter keeps an access log

A rate limiter works by remembering who did what and when. The keys look innocuous, and they are records about people.

text
ratelimit:ip:203.0.113.45:2026-08-28T09:14   -> 63
ratelimit:user:8241:login                    -> 4

An IP address is personal data under the GDPR, and a per-IP limiter is therefore a log of access times tied to an identifier, sitting in a datastore that usually has no retention policy because nobody thought of it as storage. Login attempt counters are the same thing with a sharper edge, since they record failed authentication against a named account.

Two things follow, and neither is expensive. Give every limiter key an explicit TTL, sized to the window rather than left to whatever the instance does under pressure, so the data disappears because you decided it should rather than because memory ran out. And keep the instance in a jurisdiction you can account for: where the data physically rests and who is a processor for it applies to a counter exactly as it applies to a database row, and the counter is easier to forget.

A note for anyone who arrived here from biochemistry

"Rate-limiting step" is a standard term in enzyme kinetics, and on that exact phrase the biochemistry sense outranks the software one: the neighbouring searches are for the rate-limiting enzyme of glycogenolysis and the rate-limiting step of cholesterol synthesis. If that is what you were looking for, nothing on this page will help. The two fields borrowed the same words for the same underlying idea, which is the slowest stage setting the pace of everything downstream.

How Runsite handles it

The precise, per-user half of the table above is application code, and it needs a counter that every instance of that application shares. Managed Redis hosted in the EU is where that counter goes, on the same invoice as the application itself, which means one network hop rather than a round trip to another provider on every limited request.

To be clear about what we do not do: there is no built-in limiter at the edge, so the coarse layer is yours to place. What the platform removes is the operational part — an instance with a memory ceiling set by the plan rather than left at a default, so the eviction problem above does not arrive by accident. Servers are in Germany as of writing and a signed GDPR data processing agreement covers every plan including the free one, which is the piece the previous section is about: those counters are personal data and they are sitting somewhere specific.

Being fair about the trade: none of the algorithms in this article require a managed anything, and a single-instance application with a counter in process memory is a perfectly good limiter until the day you run two instances. Setup details are in the Runsite docs.

The short version

  • 429 is not an error, in the words Google highlights from the twelve-year-old Stack Overflow answer that is the only developer-facing result on the whole first page. It is the server successfully telling you to slow down.
  • The first page of results for this status code is written for end users: clear your cache, disable extensions, check your WordPress plugins, change your IP. Correct for them, useless for you.
  • Retry-After is the header that joins both sides, and it takes two forms: a number of seconds or an HTTP-date. Clients need to parse both; the date form is what a limiter resetting on a clock boundary sends.
  • RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset let a client slow down before it hits the wall. They are an IETF draft as of writing, and X-RateLimit-* remains widespread.
  • When there is no header, back off exponentially with jitter. The jitter is the half that matters, because synchronised clients doubling on the same schedule return together.
  • Cap the backoff and cap the attempts. Work that will never succeed belongs somewhere a human can see it, not in an infinite retry.
  • Model APIs are a distinct case. Their quotas are usually two-dimensional — requests per minute and tokens per minute — so the same call count can pass and then fail because the prompts got longer. Those calls belong behind a queue.
  • Three algorithms, and the difference is the window boundary: fixed window allows a double burst across it, sliding window does not, token bucket allows a deliberate burst you configured.
  • "Don't use Redis as a rate limiter" is right about one layer. Coarse limits belong at the edge where they are cheap; per-user and per-tenant limits belong in the application, which is the only place that knows what the numbers mean.
  • The reason the application layer needs Redis is that the counter must be shared. A per-process counter turns a limit of 100 per minute into 100 per instance.
  • Limiter counters are not cache. On an instance with an allkeys-lru policy they can be evicted, and an evicted counter means the limit silently stops applying.
  • A rate limiter is an access log. IP addresses are personal data, login counters record failed authentication, and both want an explicit TTL and a jurisdiction you can account for.
FAQ

Frequently Asked Questions

Common questions about this service.

Read the response before guessing. If it carries a `Retry-After` header, that is the authoritative answer and it takes one of two forms: a number of seconds, or an HTTP-date giving the moment the limit lifts. Many APIs also send `RateLimit-Reset`, which tells you how long the current window has left. If neither is present, back off exponentially with randomness: double the wait after each attempt, then pick a random delay between zero and that value, so that clients which failed at the same moment do not all return at the same moment. Put a ceiling on the delay, because a retry scheduled an hour away is usually worse than failing cleanly, and a limit on the number of attempts, because work that cannot succeed should surface somewhere visible rather than loop. For model APIs the window is often a minute wide and measured in tokens as well as requests, so retrying after a second achieves nothing.

For a developer hitting someone else's API, there are three fixes and only one of them is code. First, honour `Retry-After` if it is sent and use exponential backoff with jitter if it is not, which resolves the transient case. Second, find the documented limit and make your consumption of it visible in your own monitoring, because the common cause is not a burst but steady growth that crossed a threshold nobody was watching. Third, move the calls that can wait out of the request path and into a queue, so a temporary refusal becomes a retry rather than a user-facing error. If instead you are seeing this in a browser on someone else's site, the usual advice applies and it is different advice: wait a few minutes, clear the site's cookies, disable extensions that might be refreshing in the background, and if you are on a shared or corporate connection, consider that the limit may be counting everyone on your IP address rather than you.

Yes, for the layer where it belongs, and the well-known objections are about a different layer. Rate limiting happens at several levels: a CDN or edge sees IP and path and is the right place to absorb floods cheaply, while your application is the only layer that knows who the authenticated user is, which tenant they belong to and what their plan entitles them to. Redis is for that second kind. The reason it is needed at all is that the counter has to be shared: an application running several instances with a counter in process memory does not enforce 100 requests per minute, it enforces 100 per instance. Implementations usually build on atomic increments with an expiry, or on a token bucket; the `go-redis/redis_rate` package implements GCRA, also known as leaky bucket. The failure to plan for is that counters are durable data. Kept in a cache instance with an eviction policy that can delete them, they will be deleted under memory pressure and the limit will stop applying without any error.

The terms are used interchangeably often enough that you should check what a given API means by them, but the useful distinction is about what happens to the excess request. Rate limiting rejects it: you are over the quota, so the request gets a 429 and does not run. Throttling slows it down: the request is accepted and queued or delayed so that it completes at a rate the system can sustain, which is why throttling is often invisible as latency rather than as an error. Rejecting is the right behaviour for a public API, where an honest refusal lets the client back off intelligently and a hidden delay just moves the queue into somebody's connection pool. Delaying suits internal traffic where the work must eventually happen and the caller can afford to wait. Some systems do both at once, throttling up to a threshold and rejecting past it, which is effectively what a token bucket with a queue in front of it produces.

Your app deserves to be online

€5 of credit on signup. Deploy in under a minute. No credit card needed.