429 Too Many Requests: Both Sides of Rate Limiting

How to handle being rate limited without making it worse, and how to implement limits that do not punish well behaved clients.

Share
429 Too Many Requests: Both Sides of Rate Limiting. Abstract networking illustration in orange and dark grey on debugly.dev

The short answer

Being limited: read Retry-After, back off exponentially with jitter, and never retry immediately in a loop.

if (res.status === 429) {
  const wait = Number(res.headers.get("retry-after")) * 1000
            || Math.random() * Math.min(30000, 1000 * 2 ** attempt);
  await sleep(wait);
}

Implementing limits: use a sliding window or token bucket, key on something meaningful, and always send the headers that let clients behave well:

RateLimit-Limit: 100
RateLimit-Remaining: 23
RateLimit-Reset: 47
Retry-After: 47

Handling being limited

Read Retry-After first

Most providers tell you exactly how long to wait. Ignoring it and using your own backoff means either retrying too early, which wastes a request and may extend your penalty, or waiting far longer than necessary.

function retryAfterMs(res, attempt) {
  const header = res.headers.get("retry-after");
  if (header) {
    const seconds = Number(header);
    if (!Number.isNaN(seconds)) return seconds * 1000;
    const date = Date.parse(header);              // it can be an HTTP date
    if (!Number.isNaN(date)) return Math.max(0, date - Date.now());
  }
  return Math.min(30_000, 1000 * 2 ** attempt);
}

Retry-After can be either a number of seconds or an HTTP date. Handling only the first is a common bug and it produces NaN delays, which usually means retrying instantly.

Always jitter

const delay = Math.random() * Math.min(cap, base * 2 ** attempt);

Full jitter, meaning a random value between zero and the computed delay.

Without jitter every client that was limited at the same moment retries at the same moment. You have built a synchronised herd that hits the service the instant it recovers, and gets limited again. This is one of the most common ways a brief rate limit becomes a sustained one.

Respect the remaining count before you hit the wall

The better pattern is not hitting 429 at all:

const remaining = Number(res.headers.get("ratelimit-remaining"));
const reset = Number(res.headers.get("ratelimit-reset"));

if (remaining < 5) {
  await sleep((reset * 1000) / Math.max(1, remaining));   // spread the rest
}

Slowing down as you approach the limit is much better than sprinting into it and being blocked. For a bulk job, this turns an unpredictable stop-start pattern into steady throughput.

Limit your own concurrency

A large share of rate limit problems are self inflicted by unbounded parallelism:

// hits the limit immediately
await Promise.all(ids.map(id => api.fetch(id)));

// bounded
import pLimit from "p-limit";
const limit = pLimit(5);
await Promise.all(ids.map(id => limit(() => api.fetch(id))));

Promise.all over a thousand items opens a thousand requests. Providers see a burst and limit you, correctly.

Share the budget across processes

If you run ten instances each doing their own backoff, you are collectively ten times over the limit and each one thinks it is behaving.

For a shared quota you need a shared counter, usually Redis. A token bucket in Redis with a Lua script for atomicity is the standard approach, and the alternative is dividing the limit by the instance count, which wastes capacity but is simple and works.

Implementing rate limits

Choose the algorithm deliberately

Fixed window counts requests per calendar minute. Simple, and it allows a burst of double the limit across a boundary: 100 requests at 10:00:59 and 100 more at 10:01:00.

Sliding window counts over the trailing period. Smoother, slightly more expensive, and it is what most people should use.

Token bucket refills at a steady rate up to a capacity. Allows a controlled burst then settles to the sustained rate, which usually matches how real clients behave. My default.

Leaky queue processes at a fixed rate. Good for protecting a downstream system with fixed capacity, and it adds latency rather than rejecting.

-- token bucket in Redis, atomic
local key = KEYS[1]
local rate = tonumber(ARGV[1])        -- tokens per second
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])

local bucket = redis.call("HMGET", key, "tokens", "ts")
local tokens = tonumber(bucket[1]) or capacity
local ts = tonumber(bucket[2]) or now

tokens = math.min(capacity, tokens + (now - ts) * rate)

if tokens < cost then
  redis.call("HMSET", key, "tokens", tokens, "ts", now)
  redis.call("EXPIRE", key, math.ceil(capacity / rate) * 2)
  return {0, tokens}
end

tokens = tokens - cost
redis.call("HMSET", key, "tokens", tokens, "ts", now)
redis.call("EXPIRE", key, math.ceil(capacity / rate) * 2)
return {1, tokens}

The Lua script matters. Doing read-modify-write from the application is a race under concurrency, and rate limiters are by definition running under concurrency.

Key on the right thing

By IP is the default and it is wrong more often than people think. Corporate NAT and mobile carrier NAT put thousands of users behind one address, so you limit an entire office because one person was aggressive. Meanwhile an attacker with a proxy pool bypasses it entirely.

By authenticated user or API key is much better where available. Actual identity, actual accountability.

By endpoint cost for anything where operations differ in expense. A search that scans and a health check that returns a constant should not consume the same budget. Weight them.

Tiered by plan, if you have plans.

A practical combination: authenticated requests limited by key, unauthenticated limited by IP with a lower ceiling, and a global limit as a backstop.

Always send the headers

res.set({
  "RateLimit-Limit": limit,
  "RateLimit-Remaining": Math.max(0, remaining),
  "RateLimit-Reset": resetSeconds,
});

if (!allowed) {
  res.set("Retry-After", String(resetSeconds));
  return res.status(429).json({
    error: "rate_limit_exceeded",
    message: `Limit of ${limit} requests per minute exceeded. Retry in ${resetSeconds}s.`,
    retry_after: resetSeconds,
  });
}

A 429 with no Retry-After forces every client to guess, and their guess will be worse than your answer. The headers are how you get well behaved clients, and their absence is why so many integrations hammer APIs.

Include the limit in the error body too. A client library can parse it, and a human reading a log gets the number without looking it up. Same argument as error messages being an interface.

Fail open, carefully

If your Redis is down, does everything get rejected?

try {
  allowed = await checkLimit(key);
} catch (err) {
  logger.error({ err }, "rate limiter unavailable, failing open");
  metrics.increment("ratelimit.unavailable");
  allowed = true;
}

For most APIs, failing open is correct. A rate limiter outage should not be a service outage, and the limiter is protecting against abuse rather than being load bearing for correctness.

For an endpoint where the limit is protecting something expensive or dangerous, fail closed. Decide deliberately per endpoint rather than accepting whichever your library defaults to, and make sure you have a metric on it so you know when you are running unprotected.

Testing it

Two things worth testing and rarely tested.

That the limit engages at the right threshold, and that the headers are correct at the boundary.

That your client backs off properly. Mock a 429 with Retry-After and assert the client waited approximately that long, and that a sustained 429 does not produce a tight retry loop. A client that retries a 429 immediately, forever, is a denial of service against the provider and it exists in production more often than you would hope.