Telling a Client to Slow Down

429 with Retry-After, headers that let a client pace itself, and the jittered backoff that stops every retry landing together.

SyntaxHTTP/1.1 429 Too Many Requests + Retry-After: 30

A limit the client cannot see is a limit the client will hit. Two things have to be true: the rejection must be unambiguous, and a well-behaved client must be able to avoid it in the first place.

429 and Retry-After

Reject with 429 Too Many Requests (RFC 6585) and a Retry-After. That header takes either delay-seconds or an HTTP-date; send seconds, because a great deal of client code assumes an integer. Do not use 403 for throttling — it is indistinguishable from an authorisation failure and most client libraries never retry it. GitHub returns either 403 or 429 for primary rate limits, which is exactly the ambiguity worth avoiding in a new API.

Headers that let a client pace itself

Send the budget on successful responses too, so a client can slow down before it is rejected. GitHub's x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-used, x-ratelimit-reset (UTC epoch seconds) and x-ratelimit-resource are the de facto pattern. The IETF is standardising RateLimit and RateLimit-Policy as structured fields, but draft-ietf-httpapi-ratelimit-headers is still an Internet-Draft and its field names have changed between revisions, so do not treat it as settled.

What to limit, and the herd

Limit per API key for fairness between integrations, per tenant to protect shared capacity, and per IP only as abuse control — NAT puts thousands of users behind one address. Then make the retry jittered. If every rejected client waits exactly Retry-After, they all return in the same millisecond and the second spike is worse than the first. AWS's recommendation is full jitter: sleep = random(0, min(cap, base * 2 ** attempt)).

Example

Example · javascript
// httpClient.js — a client that respects the server's pacing signals.
const BASE_MS = 100;
const CAP_MS  = 20_000;

function fullJitter(attempt) {
  // AWS's "full jitter": sleep = random(0, min(cap, base * 2 ** attempt)).
  // 2 ** attempt is the shorthand; Math.pow(2, attempt) is the long form.
  return Math.random() * Math.min(CAP_MS, BASE_MS * 2 ** attempt);
}

function retryAfterMs(res) {
  const h = res.headers.get('retry-after');
  if (!h) return null;
  const seconds = Number(h);
  if (Number.isFinite(seconds)) return seconds * 1000;        // delay-seconds form
  const when = Date.parse(h);                                 // HTTP-date form
  return Number.isNaN(when) ? null : Math.max(0, when - Date.now());
}

export async function request(url, init = {}, maxAttempts = 5) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, init);

    // Retry 429 and 5xx. Never retry other 4xx — they will not change.
    const retryable = res.status === 429 || res.status >= 500;
    if (!retryable || attempt === maxAttempts - 1) return res;

    // Proactive: if the budget is nearly gone, slow down before being told to.
    const remaining = Number(res.headers.get('x-ratelimit-remaining'));

    // Honour the hint, then add jitter on top so we do not come back in
    // lockstep with every other client the server just rejected.
    const wait = (retryAfterMs(res) ?? 0) + fullJitter(attempt);
    await new Promise((r) => setTimeout(r, wait));

    if (Number.isFinite(remaining) && remaining === 0) {
      const reset = Number(res.headers.get('x-ratelimit-reset')) * 1000;  // epoch seconds
      if (Number.isFinite(reset)) await new Promise((r) => setTimeout(r, Math.max(0, reset - Date.now())));
    }
  }
}

When to use it

  • One integration's retry loop saturating a shared database connection pool
  • Choosing a status code for throttling so client SDKs retry instead of failing hard
  • Publishing a documented quota that partners can build an exporter against
  • Stopping thousands of clients from retrying in the same second after an outage

More examples

Server side: publish the budget, not just the rejection

A fixed window is the cheapest thing that works and its known flaw is the boundary: a client can spend 5,000 at 10:59:59 and 5,000 more at 11:00:00. Move to a sliding window or token bucket when that doubled burst actually threatens capacity, not before — but keep the headers identical so clients do not notice the change.

Example · javascript
// A fixed-window counter in Redis, keyed per API key.
const LIMIT = 5_000, WINDOW_S = 3_600;

export async function rateLimit(req, res, next) {
  const bucket = Math.floor(Date.now() / 1000 / WINDOW_S);
  const key = `rl:${req.principal.apiKeyId}:${bucket}`;

  const [used] = await redis.multi().incr(key).expire(key, WINDOW_S).exec();
  const resetAt = (bucket + 1) * WINDOW_S;      // UTC epoch seconds

  // Sent on success too — that is the whole point. A client that only learns
  // its budget from a 429 has no way to avoid the 429.
  res.set({
    'x-ratelimit-limit': String(LIMIT),
    'x-ratelimit-remaining': String(Math.max(0, LIMIT - used)),
    'x-ratelimit-used': String(used),
    'x-ratelimit-reset': String(resetAt),
  });

  if (used > LIMIT) {
    // Seconds, not a date: too much client code does Number(header).
    res.set('Retry-After', String(resetAt - Math.floor(Date.now() / 1000)));
    return res.status(429).json(problem('rate-limited', 429,
      { retryable: true, detail: `${LIMIT} requests per hour per API key` }));
  }
  next();
}

The draft RateLimit fields, and why to be careful

Worth reading so you recognise these headers when a gateway emits them, and worth not standardising on until the draft lands: the field names changed between revisions, so a client written against an older draft silently sees no limits at all. Emit the x-ratelimit-* set as the contract and these in addition if you like.

Example · http
# draft-ietf-httpapi-ratelimit-headers-10 (September 2025) — an Internet-Draft,
# NOT an RFC. Structured fields: a named policy, quota q, window w seconds.
RateLimit-Policy: "default";q=100;w=10
RateLimit: "default";r=50;t=30          # r = remaining, t = seconds to more quota

# Two policies, one field line. A comma-separated list and repeated field
# lines are the same thing in HTTP — these two forms are equivalent:
RateLimit-Policy: "permin";q=50;w=60, "perhr";q=1000;w=3600

RateLimit-Policy: "permin";q=50;w=60
RateLimit-Policy: "perhr";q=1000;w=3600

# Earlier revisions of the same draft spelled it differently, e.g.
#   RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset
# which is why the x-ratelimit-* convention is still what most APIs ship.

Which key the limit counts against

Apply this when a single global limit is both too loose to protect the database and too tight for your largest customer. The last comment is the part that gets missed: with several limits in play, publishing only the per-key numbers tells a client it has 4,000 requests left while the tenant limit is about to reject it.

Example · javascript
// The same request can be counted several ways, and the reason differs.
function limitsFor(req) {
  return [
    // Fairness between integrations sharing one tenant's quota.
    { key: `key:${req.principal.apiKeyId}`, limit: 5_000, window: 3_600 },

    // Protecting shared capacity: one tenant must not starve the others.
    { key: `tenant:${req.principal.accountId}`, limit: 20_000, window: 3_600 },

    // Expensive endpoints get their own budget, so a report crawler cannot
    // consume the quota that the checkout flow needs.
    ...(req.route.path.startsWith('/reports')
      ? [{ key: `reports:${req.principal.accountId}`, limit: 60, window: 3_600 }]
      : []),

    // Abuse control only. NAT and corporate proxies put thousands of real
    // users behind one address, so never make this the primary limit.
    { key: `ip:${req.ip}`, limit: 1_000, window: 60 },
  ];
}
// Report the budget of whichever limit is closest to exhausted, so the
// headers describe the constraint the client will actually hit first.

Discussion

  • Be the first to comment on this lesson.