Skip to content
unzoi docs

Rate limits and backoff

429 means two different things here, and treating them the same way produces either a client that hangs for a day or one that hammers a wall.

The two 429s

Per-minute rateMonthly quota
Status429429
retry-after1Seconds until the month resets — possibly days
x-ratelimit-remaining0Usually non-zero
x-quota-remainingNon-zero0
Retrying helpsYes, in a secondNo

x-quota-remaining: 0 is the discriminator. Every correct client checks it, and most incorrect ones do not.

Do not back off exponentially from the rate limit

The per-minute bucket refills continuously, not at a wall-clock boundary. retry-after: 1 is accurate: a second really is enough. A client that doubles its way from 1 to 60 seconds spends 59 of them idle on allowance it already paid for — and it does this precisely when it is busiest.

Exponential backoff is for 503, where the server is actually unwell and pressure makes it worse.

A correct client

async function call(url, key) {
  for (let attempt = 0; ; attempt++) {
    const response = await fetch(url, { headers: { "x-api-key": key } });

    if (response.status === 429) {
      // Not transient. Retrying burns the rest of the month.
      if (response.headers.get("x-quota-remaining") === "0") {
        throw new QuotaExhausted(Number(response.headers.get("retry-after")));
      }
      if (attempt >= 5) throw new Error("rate limited repeatedly");
      // Transient, and one second is the honest wait.
      await sleep(Number(response.headers.get("retry-after") ?? 1) * 1000);
      continue;
    }

    // The server, not you. Back off properly here.
    if (response.status === 503 && attempt < 3) {
      await sleep((2 ** attempt) * 1000 + Math.random() * 500);
      continue;
    }

    return response;
  }
}

The jitter on the 503 path matters if you have several workers: without it they retry in lockstep and arrive together every time.

Not hitting it in the first place

Backoff is the fallback. These are cheaper:

  • Bound your concurrency. Twenty parallel requests exhaust a per-minute bucket in the first second and spend the remaining 59 retrying. A semaphore of 4–8 with a queue behind it finishes sooner.
  • Ask for bigger pages. limit=100 is one tenth of the requests of limit=10 for the same results, and each page is billed.
  • Use /stories. One clustered request replaces a search plus client-side deduplication.
  • Cache by id. An article fetched with /doc/{id} is immutable. There is no reason to fetch it twice.
  • Read the headers you already have. Every response carries x-ratelimit-remaining; a client that slows down at 10% remaining never sees a 429.

Pacing from the headers

let pauseUntil = 0;

function pace(response) {
  const remaining = Number(response.headers.get("x-ratelimit-remaining") ?? Infinity);
  const limit = Number(response.headers.get("x-ratelimit-limit") ?? 0);
  // Below 10% of the bucket, spread the rest of the minute out rather than
  // sprinting into a 429 and retrying.
  if (limit && remaining < limit * 0.1) {
    pauseUntil = Date.now() + 1000;
  }
}

On MCP

The rate limit is still a 429 with retry-after — retry the same JSON-RPC message. The quota is not: it arrives as a tool error carrying "code": "quota_exceeded" on an open session, so an agent can report it rather than concluding the server is down. The shapes are here.