Rate limits and credits at scale

Pace calls to a per-minute limit with a token bucket, share one limit across workers and processes, read credit headers, and alert before credits run out.

  • 45 min
  • Advanced

Lesson two made retries safe. This lesson is about needing fewer of them. At scale, almost every 429 you receive is self-inflicted: workers that each think they own the whole limit, a backfill sharing a key with live traffic, a burst at the top of every minute. And almost every credit outage is predictable days in advance from numbers that arrive on every response.

The goal is a client that never trips the per-minute limit in normal operation, reacts to the one 429 that slips through by slowing everyone down, and stops cleanly, with an alert already sent, when credits run out.

Know the ceilings you are pacing to

APIVerve enforces a per-minute request limit per API key, set by the plan. The rate limits page renders the current table from the published plans file. At the time of writing:

PlanRequests per minuteConcurrent calls (published)
Free51
Starter605
Pro18020
MegaNo per-minute limit50

Four details change how you design around those numbers.

  • The window slides. The limiter counts your requests in the last 60 seconds, not per clock minute. On a rate-limit 429, retry-after is the number of seconds until the oldest request in that window ages out. Sixty calls fired in the first second of a minute lock you out for the next 59; sixty calls spread evenly never trip it.
  • The effective limit can be lower than the plan's. A key can be capped below its plan in the dashboard, and the lowest applicable cap wins. Server-side, x-rate-limit-limit on a response reports the limit actually in force and x-rate-limit-remaining what is left in the window. Neither is readable from browser JavaScript, because only the four credit headers are exposed cross-origin.
  • Sub-keys have their own window, but share the account's credits. A sub-key is rate limited separately from the main key and from other sub-keys, and can carry its own lower limit. Credits are billed to the account. So sub-keys isolate pace, not budget.
  • "No per-minute limit" is not unlimited. Mega has no per-minute ceiling, but the credit allowance and the published concurrency ceiling still apply, and the edge has a coarse flood limit for every key.

What happens when you overrun

The 429 itself is the smaller cost. When the origin rate-limits a key, the edge starts holding that key in a cooldown for the rest of the window (the rate limits page describes it as around 50 seconds). During the cooldown the edge answers 429 itself, with retry-after set to the seconds remaining, without forwarding anything. Retrying during the cooldown does not shorten it.

The cooldown applies to the key, not to the worker that caused it. On a shared key, one worker that overruns the window pauses every other worker using that key, including your production traffic. This is the strongest argument for giving each workload its own sub-key: a backfill that overruns its own window stalls only itself.

A separate guard covers loops. When the same request keeps failing the same way, the edge throttles that exact request and replays the original error, so a stuck retry loop on a bad input does not reach the API at all. It is not a rate limit you should design around; it is a sign the loop should have stopped already.

A token bucket sized to the window

A token bucket is the standard client-side pacer. Tokens drip in at a fixed rate up to a capacity, and every call takes one. The capacity allows a small burst; the rate sets the long-run pace.

Sizing it against a sliding 60-second window has a precise rule. In any 60-second span the bucket can release at most its full capacity plus everything that refilled during those 60 seconds:

capacity + ratePerSecond * 60  <=  limitPerMinute

For a Starter key at 60 per minute, a capacity of 6 leaves room for a rate of (60 - 6) / 60 = 0.9 calls per second. That guarantees you can never exceed the window from this bucket, whatever your call pattern. Leave a further margin if anything else uses the same key.

token-bucket.mjs
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

export class TokenBucket {
  constructor({ ratePerSecond, capacity }) {
    Object.assign(this, { ratePerSecond, capacity });
    this.tokens = capacity;
    this.last = Date.now();
    this.pausedUntil = 0;
  }
  static forLimit(limitPerMinute, capacity = Math.max(1, Math.floor(limitPerMinute / 10))) {
    return new TokenBucket({ capacity, ratePerSecond: (limitPerMinute - capacity) / 60 });
  }
  #refill(now) {
    this.tokens = Math.min(this.capacity, this.tokens + ((now - this.last) / 1000) * this.ratePerSecond);
    this.last = now;
  }
  async take() {
    for (;;) {
      const now = Date.now();
      if (now < this.pausedUntil) { await sleep(this.pausedUntil - now); continue; }
      this.#refill(now);
      if (this.tokens >= 1) { this.tokens -= 1; return; }
      await sleep(((1 - this.tokens) / this.ratePerSecond) * 1000);
    }
  }
  pause(ms) {                       // a 429 slipped through: everyone waits
    this.pausedUntil = Math.max(this.pausedUntil, Date.now() + ms);
    this.tokens = 0;
  }
}

The pause() method is what turns a 429 into a fleet-wide slowdown instead of one worker's private problem. When any call sees a rate-limit 429, it calls bucket.pause(retryAfterMs) and every worker waiting on take() waits too, which is exactly what the edge cooldown is going to enforce anyway.

Concurrency is a separate ceiling

A rate limit is about how many calls start per minute. Concurrency is how many are in flight at once. Slow endpoints (rendering, OCR, crawling) can hit the concurrency ceiling while far under the rate limit. Run a fixed pool of workers matched to your plan's published concurrency, and take a token before each call:

pool.mjs
import { TokenBucket } from './token-bucket.mjs';
import { resilientCall } from './resilient-call.mjs';
import { RateLimitError, CreditsExhaustedError } from './errors.mjs';
import { parseRetryAfter } from './retry-after.mjs';

export async function runPool(items, { api, limitPerMinute = 60, concurrency = 5, toParams }) {
  const bucket = TokenBucket.forLimit(limitPerMinute);
  const results = new Array(items.length);
  const again = [];                          // items to resend after a 429
  let next = 0;
  let halted = null;

  async function worker() {
    while (!halted && (again.length || next < items.length)) {
      const i = again.length ? again.shift() : next++;
      await bucket.take();
      try {
        results[i] = { ok: true, data: await resilientCall(api, toParams(items[i]), { maxAttempts: 1 }) };
      } catch (err) {
        if (err instanceof RateLimitError) {
          bucket.pause(parseRetryAfter(err.retryAfter, Date.now()) ?? 30_000);
          again.push(i);                      // resend it once the pause is over
          continue;
        }
        if (err instanceof CreditsExhaustedError) { halted = err; break; }
        results[i] = { ok: false, error: err };
      }
    }
  }
  await Promise.all(Array.from({ length: concurrency }, worker));
  return { results, halted };
}

Here the pool owns rate-limit handling (maxAttempts: 1 turns off the per-call retry so the shared bucket decides when to resend), while transient 5xx failures are recorded for a later pass. In a long-running service you would let resilientCall retry 5xx itself and keep only the 429 path in the pool.

Check yourself

A Starter key allows 60 requests per minute on a sliding window. Your token bucket has capacity 10. What is the highest refill rate that can never exceed the limit in any 60-second span?

Run every example on your own key. Free plan, no card.

Scaling up?

Volume pricing, custom SLAs, and dedicated support for high-traffic teams.

Contact sales