Skip to content
AF
Cookbook

Recipe: A Rate-Limit-Aware Fetch Wrapper

Last verified August 12, 2026 · 14 min read

Fitness providers meter reads per consented user, so one runaway backfill starves that user and adding workers makes it worse. This recipe is a fetch wrapper that tracks a per-user budget, honours Retry-After in both the delay-seconds and HTTP-date forms, backs off 5xx with full jitter so a recovering provider does not get a synchronized stampede, opens a circuit that skips and records a gap rather than hammering a dead endpoint, and never replays a non-idempotent call it cannot prove failed. The clock, sleep, jitter source and fetch are injected, so the whole fault-injection suite runs against a fake clock in milliseconds. Nothing throws on an HTTP outcome: every call returns an envelope so the caller parks the window and moves on.

Day 400 of a three-year backfill, the provider starts answering 429, your worker retries into the wall, the pod hits its deadline, and the next attempt starts the user's history again from the beginning. That is the expensive failure. The cheap one is a response that lands just inside your timeout: nothing errors, nothing alerts, and a backfill budgeted for twenty minutes is still running tomorrow.

Why a per-user budget changes the shape#

The single fact that makes fitness rate limits different is that at least one major provider meters reads per consented user rather than per app — the ranked causes and the header names are in the Fitbit 429 fix, which carries the published figure with its verify caveat attached. Two consequences follow. One runaway loop starves exactly one user and touches nobody else's quota, so the fix is usually local to how you call one person's endpoints. And you cannot buy your way out by adding workers, because more workers on the same user is more pressure on the same bucket.

So the budget in this wrapper is keyed by user, and it re-syncs from the provider's own rate-limit headers rather than trusting the number you configured. Do not build anything around a specific requests-per-hour figure: make the ceiling a parameter, read the allowance at runtime where the provider reports it, and assert on degradation rather than on throughput. Testing rate limits and outages makes the case for that at length, including why a unit test of your backoff helper cannot fail.

The implementation#

The canonical file is cookbook/rate-limit-fetcher.mjs in this site's repository, and cookbook/rate-limit-fetcher.test.mjs runs against it on every CI run. The listing below is a verbatim copy. It has no runtime dependencies, needs Node 20 or newer, and injects fetch, the clock, sleep and the jitter source — so the whole fault-injection suite runs against a fake clock in milliseconds of real time.

/**
 * rate-limit-fetcher.mjs — a fetch wrapper that survives a per-user quota.
 *
 * WHAT THIS IMPLEMENTS
 *   Fitness providers meter reads PER CONSENTED USER, not per app, so one
 *   runaway backfill starves that user and nobody else — and adding workers
 *   makes it worse, not better. This wrapper:
 *     1. Tracks a per-user request budget in a rolling window, and re-syncs it
 *        from the provider's own rate-limit headers rather than trusting a
 *        hard-coded number.
 *     2. Honours `Retry-After` on 429 in BOTH forms RFC 9110 §10.2.3 permits:
 *        delay-seconds and HTTP-date. RFC 6585 §4 only says a 429 MAY carry
 *        the header, so a missing header falls back to jittered backoff.
 *     3. Backs off 5xx with exponential backoff and FULL JITTER, so every
 *        worker does not resynchronize onto the same second when a provider
 *        recovers.
 *     4. Opens a circuit after N consecutive failures per origin and then
 *        DEGRADES — skips the call and records the gap for a later sweep —
 *        instead of hammering a provider that is already down.
 *     5. Never retries a non-idempotent call on an ambiguous failure. A POST
 *        that died in transit, or answered 5xx, may or may not have landed.
 *   Nothing throws for an HTTP outcome: every call returns an envelope, so a
 *   caller parks the window and moves on rather than crashing a worker.
 *
 * PATTERN DOCUMENTED AT
 *   https://aifitnessapi.com/fix/fitbit-api-429-rate-limit
 *   https://aifitnessapi.com/test/rate-limits-and-outages
 *
 * Clock, sleep, jitter source and fetch are all injected, so the tests below
 * run in microseconds against a fake clock and never touch a network.
 *
 * MIT — from aifitnessapi.com/cookbook
 */

/** Methods safe to replay when we cannot tell whether the call landed. */
export const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "OPTIONS", "PUT", "DELETE"]);

/** Headers providers use to report the remaining allowance, in priority order. */
const REMAINING_HEADERS = [
  "fitbit-rate-limit-remaining",
  "x-ratelimit-remaining",
  "ratelimit-remaining",
];
/** Headers reporting seconds until the window resets. */
const RESET_HEADERS = ["fitbit-rate-limit-reset", "x-ratelimit-reset", "ratelimit-reset"];

export function headerOf(res, name) {
  const h = res?.headers;
  if (!h) return null;
  if (typeof h.get === "function") return h.get(name);
  const lower = name.toLowerCase();
  for (const [k, v] of Object.entries(h)) {
    if (k.toLowerCase() === lower) return Array.isArray(v) ? v[0] : v;
  }
  return null;
}

/**
 * Parse `Retry-After` into milliseconds. RFC 9110 §10.2.3 allows delay-seconds
 * OR an HTTP-date; a parser that only calls parseInt is non-compliant and will
 * silently fall through to a default on any provider that sends the date form.
 *
 * @returns {number|null} ms to wait, clamped at 0, or null if unparseable.
 */
export function parseRetryAfter(value, nowMs) {
  if (value === null || value === undefined || value === "") return null;
  const raw = String(value).trim();
  if (/^\d+$/.test(raw)) return Number(raw) * 1000;
  const at = Date.parse(raw);
  if (Number.isNaN(at)) return null;
  return Math.max(0, at - nowMs);
}

/**
 * Exponential backoff with FULL jitter: uniform in [0, min(cap, base * 2^n)].
 * Fixed backoff passes every single-user test and then synchronizes your whole
 * fleet onto one second the moment the provider comes back.
 */
export function fullJitterDelay(attempt, { baseDelayMs = 500, maxDelayMs = 60_000, random = Math.random } = {}) {
  const exponential = Math.min(maxDelayMs, baseDelayMs * 2 ** Math.max(0, attempt - 1));
  return Math.floor(random() * exponential);
}

/** Per-user rolling request budget. Reset is a wall-clock window, not a tick. */
function createBudgetTracker({ limit, windowMs, now }) {
  const users = new Map();
  function stateFor(userId) {
    let s = users.get(userId);
    if (!s || now() >= s.resetAt) {
      s = { remaining: limit, resetAt: now() + windowMs, limit };
      users.set(userId, s);
    }
    return s;
  }
  return {
    take(userId) {
      const s = stateFor(userId);
      if (s.remaining <= 0) return { ok: false, resetAt: s.resetAt };
      s.remaining -= 1;
      return { ok: true, resetAt: s.resetAt };
    },
    /** Trust the provider's count over ours when it tells us one. */
    syncFromResponse(userId, res) {
      const s = stateFor(userId);
      for (const name of REMAINING_HEADERS) {
        const v = headerOf(res, name);
        if (v !== null && v !== undefined && v !== "" && Number.isFinite(Number(v))) {
          s.remaining = Number(v);
          break;
        }
      }
      for (const name of RESET_HEADERS) {
        const v = headerOf(res, name);
        if (v !== null && v !== undefined && v !== "" && Number.isFinite(Number(v))) {
          s.resetAt = now() + Number(v) * 1000;
          break;
        }
      }
    },
    peek: (userId) => ({ ...stateFor(userId) }),
  };
}

/**
 * Per-origin circuit breaker with a half-open probe.
 * 429 deliberately does NOT count as a failure: a provider answering 429 is
 * healthy and telling you the truth. Only 5xx and transport errors trip it.
 */
function createBreaker({ threshold, cooldownMs, now }) {
  const keys = new Map();
  const stateFor = (key) => {
    let s = keys.get(key);
    if (!s) {
      s = { failures: 0, openUntil: 0, halfOpen: false };
      keys.set(key, s);
    }
    return s;
  };
  return {
    /** @returns {{blocked: boolean, retryAtMs?: number}} */
    check(key) {
      const s = stateFor(key);
      if (s.openUntil === 0) return { blocked: false };
      if (now() < s.openUntil) return { blocked: true, retryAtMs: s.openUntil };
      s.halfOpen = true; // one probe allowed through
      return { blocked: false };
    },
    recordSuccess(key) {
      const s = stateFor(key);
      s.failures = 0;
      s.openUntil = 0;
      s.halfOpen = false;
    },
    recordFailure(key) {
      const s = stateFor(key);
      s.failures += 1;
      if (s.halfOpen || s.failures >= threshold) {
        s.openUntil = now() + cooldownMs;
        s.halfOpen = false;
      }
    },
    peek: (key) => ({ ...stateFor(key) }),
  };
}

/**
 * @param {object} options
 * @param {typeof globalThis.fetch} [options.fetch]
 * @param {() => number} [options.now]              epoch ms
 * @param {(ms: number) => Promise<void>} [options.sleep]
 * @param {() => number} [options.random]           jitter source
 * @param {{limit: number, windowMs: number}} [options.budget]
 * @param {{threshold: number, cooldownMs: number}} [options.breaker]
 * @param {number} [options.maxAttempts]
 * @param {number} [options.baseDelayMs]
 * @param {number} [options.maxDelayMs]
 * @param {number} [options.maxWaitMs]  refuse to sit on a Retry-After longer
 *                                      than this; park the window instead
 * @param {(gap: object) => void} [options.onGap]
 * @param {(url: string) => string} [options.scopeOf]  breaker key
 */
export function createRateLimitedFetcher({
  fetch: fetchImpl = globalThis.fetch,
  now = () => Date.now(),
  sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
  random = Math.random,
  budget: budgetOpts = { limit: 150, windowMs: 3_600_000 },
  breaker: breakerOpts = { threshold: 5, cooldownMs: 60_000 },
  maxAttempts = 4,
  baseDelayMs = 500,
  maxDelayMs = 60_000,
  maxWaitMs = 15 * 60_000,
  onGap = () => {},
  scopeOf = (url) => {
    try {
      return new URL(String(url)).origin;
    } catch {
      return String(url);
    }
  },
} = {}) {
  if (typeof fetchImpl !== "function") throw new TypeError("fetch is required");

  const budget = createBudgetTracker({ ...budgetOpts, now });
  const breaker = createBreaker({ ...breakerOpts, now });
  /** Every window we did not read. A reconciliation sweep drains this. */
  const gaps = [];

  function recordGap(entry) {
    const gap = { at: now(), ...entry };
    gaps.push(gap);
    onGap(gap);
    return gap;
  }

  /**
   * @returns {Promise<{
   *   outcome: "ok"|"rate-limited"|"failed"|"ambiguous"|"skipped",
   *   response?: object, error?: Error, status: number|null,
   *   attempts: number, waitedMs: number, reason?: string,
   *   retryAtMs?: number, ambiguous?: boolean
   * }>}
   */
  async function fetchForUser(userId, url, init = {}) {
    const key = scopeOf(url);
    const method = String(init.method ?? "GET").toUpperCase();
    const idempotent = init.idempotent ?? IDEMPOTENT_METHODS.has(method);
    let attempts = 0;
    let waitedMs = 0;

    for (;;) {
      // --- Circuit first: a degrade is cheaper than a doomed request. -------
      const circuit = breaker.check(key);
      if (circuit.blocked) {
        recordGap({ userId, url: String(url), reason: "circuit-open", retryAtMs: circuit.retryAtMs });
        return {
          outcome: "skipped",
          reason: "circuit-open",
          status: null,
          attempts,
          waitedMs,
          retryAtMs: circuit.retryAtMs,
        };
      }

      // --- Then the per-user budget. One user's exhaustion is one user's. ---
      const allowance = budget.take(userId);
      if (!allowance.ok) {
        recordGap({ userId, url: String(url), reason: "budget-exhausted", retryAtMs: allowance.resetAt });
        return {
          outcome: "skipped",
          reason: "budget-exhausted",
          status: null,
          attempts,
          waitedMs,
          retryAtMs: allowance.resetAt,
        };
      }

      attempts += 1;
      let res;
      try {
        res = await fetchImpl(url, init);
      } catch (error) {
        // Transport died. We do not know whether the server saw the request.
        breaker.recordFailure(key);
        if (!idempotent) {
          recordGap({ userId, url: String(url), reason: "ambiguous-transport-error", method });
          return { outcome: "ambiguous", ambiguous: true, error, status: null, attempts, waitedMs, reason: "non-idempotent" };
        }
        if (attempts >= maxAttempts) {
          recordGap({ userId, url: String(url), reason: "transport-error" });
          return { outcome: "failed", error, status: null, attempts, waitedMs, reason: "transport-error" };
        }
        const delay = fullJitterDelay(attempts, { baseDelayMs, maxDelayMs, random });
        await sleep(delay);
        waitedMs += delay;
        continue;
      }

      budget.syncFromResponse(userId, res);

      if (res.status === 429) {
        // A 429 is a healthy answer, so it does not trip the breaker — it
        // consumes the retry budget and then parks the window.
        const retryAfter =
          parseRetryAfter(headerOf(res, "retry-after"), now()) ??
          resetHeaderMs(res) ??
          fullJitterDelay(attempts, { baseDelayMs, maxDelayMs, random });

        if (attempts >= maxAttempts || waitedMs + retryAfter > maxWaitMs) {
          recordGap({ userId, url: String(url), reason: "rate-limited", retryAtMs: now() + retryAfter });
          return {
            outcome: "rate-limited",
            response: res,
            status: 429,
            attempts,
            waitedMs,
            reason: "rate-limited",
            retryAtMs: now() + retryAfter,
          };
        }
        await sleep(retryAfter);
        waitedMs += retryAfter;
        continue;
      }

      if (res.status >= 500) {
        breaker.recordFailure(key);
        if (!idempotent) {
          // The server may have applied it before failing. Replaying a POST
          // here is how one workout becomes two.
          recordGap({ userId, url: String(url), reason: "ambiguous-5xx", method });
          return { outcome: "ambiguous", ambiguous: true, response: res, status: res.status, attempts, waitedMs, reason: "non-idempotent" };
        }
        if (attempts >= maxAttempts) {
          recordGap({ userId, url: String(url), reason: "server-error", status: res.status });
          return { outcome: "failed", response: res, status: res.status, attempts, waitedMs, reason: "server-error" };
        }
        const delay = fullJitterDelay(attempts, { baseDelayMs, maxDelayMs, random });
        await sleep(delay);
        waitedMs += delay;
        continue;
      }

      // 2xx, 3xx and 4xx are all final. A 404 or a 401 is the caller's problem
      // to interpret; retrying it just burns the user's quota.
      breaker.recordSuccess(key);
      return { outcome: "ok", response: res, status: res.status, attempts, waitedMs };
    }
  }

  function resetHeaderMs(res) {
    for (const name of RESET_HEADERS) {
      const v = headerOf(res, name);
      if (v !== null && v !== undefined && v !== "" && Number.isFinite(Number(v))) {
        return Number(v) * 1000;
      }
    }
    return null;
  }

  return {
    fetchForUser,
    /** Windows we skipped. Feed these to the reconciliation sweep. */
    gaps,
    budgetFor: (userId) => budget.peek(userId),
    breakerState: (urlOrKey) => breaker.peek(scopeOf(urlOrKey)),
  };
}

How to adapt it#

Add your provider's header names. REMAINING_HEADERS and RESET_HEADERS are ordered lists. Put yours at the front rather than replacing the lot, so the wrapper keeps working when you add a second provider with a different convention.

Set the ceiling from the response, not from a constant. The budget.limit option is only the starting allowance for a user the wrapper has not seen yet. The moment the provider tells you what is left, that number wins. If your provider reports nothing, run your suite at a ceiling low enough that the storm case triggers every time — a job that is correct at ten requests per hour is correct at a thousand, and the reverse is not true.

Choose the breaker key deliberately. scopeOf defaults to the URL origin, which isolates providers from each other. If one provider's endpoints fail independently, key on origin plus a route family instead. Do not key it on the user: a breaker exists to protect a dependency, and a per-user breaker cannot see that the dependency is down.

Drain the gaps list. Every skip and every parked window pushes an entry onto gaps, and the onGap hook lets you write it somewhere durable. Those entries are the input to the reconciliation sweep that eventually re-reads what you missed; on their own in memory they are a metric, not a recovery. The read model that has to tolerate the hole in the meantime is covered in missing data and gaps.

Mark your writes honestly. The idempotency default is method-based. If a provider documents a specific POST as safe to repeat, opt that one call in explicitly rather than widening the default set, and leave everything else alone.

Cap the total, not just each call. maxWaitMs refuses to sit on a Retry-After longer than your job can afford. A worker that obediently sleeps for the full reset window is a worker that is not doing anything else, and on a per-user quota that is often the whole pool.

The contract the tests hold it to#

Each bullet is one test in cookbook/rate-limit-fetcher.test.mjs, run against a fake clock that advances only when the code sleeps.

  • A 429 carrying Retry-After: 120 waits exactly 120,000 milliseconds — not a default that happens to exceed it, and not one millisecond less — then retries once and succeeds.
  • A 429 carrying an HTTP-date Retry-After waits the right interval. A parser that only calls parseInt fails this test, and the header is permitted in both forms.
  • A 429 with no Retry-After falls back to jittered exponential backoff. It neither busy-loops nor raises on the missing header, which matters because the specification says a 429 may carry the header, not must.
  • A 429 storm that outlasts the retry budget returns a rate-limited outcome with the time to retry, records the gap, and leaves the breaker untouched: a provider answering 429 is healthy.
  • With a budget of two, user A's third call is skipped before fetch is called and reports when the window resets, while user B's call goes through on their own bucket. The gap is recorded once.
  • A budget window that rolls over restores the allowance, and a response reporting zero remaining sets the budget to zero immediately — the server's count beats the local counter.
  • Three 503 responses back off with full jitter, and every delay lands inside its own bound: zero is a legitimate draw, so is a value near the exponential ceiling, and the ceiling itself is clamped at the configured maximum. A separate property test draws sixteen hundred delays across eight attempt numbers and asserts each one stays in range.
  • The breaker opens after the configured number of consecutive failures. The next call makes no request at all: it returns a skipped outcome with a retry time and records a gap, so the sweep knows which window was never read.
  • After the cooldown, one probe is allowed through, and a good probe closes the circuit. Breakers are per origin, so one dead provider does not stop the others.
  • A POST that dies in transit is reported as ambiguous after exactly one attempt, with no sleep and no retry — the server may have applied it. The same holds for a PATCH that comes back 502.
  • The same transport failure on a GET is retried, and still gives up in finite time with a recorded gap.
  • A 404 is final: one call, no retry, no quota burned on a URL that will keep being wrong.

What it deliberately does not do#

It does not cache, and caching is the largest single reduction available to most integrations — most historical fitness data never changes. It does not batch or debounce, which is the other big one. And no amount of fault injection can tell you how the real provider enforces its limit: whether the bucket is fixed or sliding, whether repeated violations compound, or whether it degrades by dropping requests instead of answering 429. What replaces that is one deliberate staging run per provider against a real consented account, driven hard enough to see an actual 429 and record what the headers said.

Frequently asked questions

Why does a 429 not count towards opening the circuit?
A provider answering 429 is healthy and telling you the truth about your allowance. Tripping a breaker on it would degrade a service that is working correctly, and it would keep degrading for as long as you are over quota. In this wrapper only 5xx responses and transport errors increment the failure counter; a 429 consumes the retry budget instead, and once that budget is spent the call returns a rate-limited envelope with the time to retry so the caller can park the window.
What makes full jitter better than a fixed backoff here?
A fixed backoff passes every single-user test you will ever write and then synchronizes your whole fleet onto the same second the moment a provider recovers. Full jitter draws the delay uniformly from zero up to the exponential ceiling, which spreads recovery arrivals across the window instead of stacking them. That failure only shows up in a test with more than one user in the fixture, which is why the contract below asserts on the distribution rather than on a single sleep.
Why does the wrapper return an envelope instead of throwing on failure?
Because a backfill worker handling hundreds of windows should park the ones it could not read and carry on, not die on the first outage. Every call resolves with an outcome, the attempt count, how long it waited, and when it is worth trying again. Skipped and rate-limited outcomes also push an entry onto a gaps list, which is the input to the reconciliation sweep that eventually re-reads what you missed.
How does the wrapper decide a call is too risky to replay?
It looks at the method, and you can override it per call. GET, HEAD, OPTIONS, PUT and DELETE are treated as safe to repeat; POST and PATCH are not. When a call in the unsafe set dies in transit or comes back 5xx, the outcome is marked ambiguous and returned immediately with no retry, because the server may have applied the write before failing. A provider that documents a specific POST as idempotent can opt that one call back in.

Keep reading

Elsewhere on the site

Pages that share this one’s concepts and sources, from other sections.

Next steps

Was this page useful?

Independent comparison, last reviewed August 12, 2026. Pricing, rate limits, and feature availability change often — confirm current details in each provider’s official documentation before you commit. Product and company names are trademarks of their respective owners; AIFitnessAPI is not affiliated with, endorsed by, or sponsored by any product listed here.

← All cookbook · by AIFitnessAPI