All files / packages/design-system-2-site/src/lib/zeroheight http.js

94.87% Statements 37/39
83.87% Branches 26/31
80% Functions 8/10
100% Lines 34/34

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108            7x                 1x 1x     3x 4x 4x 4x 3x 3x   1x       1x     3x 3x                               21x 21x   14x 23x 23x     21x 21x                   6x 6x 6x                   3x 3x 3x                       9x 11x 11x   6x 6x 6x 2x 6x        
/**
 * Shared HTTP helpers for the Zeroheight clients: a sliding-window rate
 * limiter, a small concurrency pool, and retry with backoff.
 */
 
/** @param {number} ms */
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
 
/**
 * Allow at most `limit` calls per `windowMs`. `acquire()` resolves once a
 * slot is free.
 * @param {{ limit: number, windowMs: number, now?: () => number, wait?: (ms: number) => Promise<void> }} opts
 */
export function createRateLimiter({ limit, windowMs, now = Date.now, wait = sleep }) {
  /** @type {number[]} */
  const stamps = [];
  let queue = Promise.resolve();
 
  async function take() {
    for (;;) {
      const t = now();
      while (stamps.length && t - stamps[0] >= windowMs) stamps.shift();
      if (stamps.length < limit) {
        stamps.push(t);
        return;
      }
      await wait(windowMs - (t - stamps[0]) + 1);
    }
  }
 
  return {
    /** @returns {Promise<void>} */
    acquire() {
      queue = queue.then(take);
      return queue;
    },
  };
}
 
/**
 * Map over `items` with at most `concurrency` calls in flight. Results keep
 * input order.
 * @template T, R
 * @param {T[]} items
 * @param {number} concurrency
 * @param {(item: T, index: number) => Promise<R>} fn
 * @returns {Promise<R[]>}
 */
export async function mapPool(items, concurrency, fn) {
  /** @type {R[]} */
  const results = new Array(items.length);
  let next = 0;
  async function worker() {
    while (next < items.length) {
      const i = next++;
      results[i] = await fn(items[i], i);
    }
  }
  await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
  return results;
}
 
export class HttpError extends Error {
  /**
   * @param {string} message
   * @param {number} status
   * @param {number | null} retryAfterMs
   */
  constructor(message, status, retryAfterMs = null) {
    super(message);
    this.status = status;
    this.retryAfterMs = retryAfterMs;
  }
}
 
/**
 * Read `retry-after` or `ratelimit-reset` (both in seconds) from a response.
 * @param {Response} res
 * @returns {number | null}
 */
export function retryAfterMs(res) {
  const raw = res.headers.get('retry-after') ?? res.headers.get('ratelimit-reset');
  const secs = raw == null ? NaN : Number(raw);
  return Number.isFinite(secs) ? secs * 1000 : null;
}
 
/**
 * Retry `fn` on 429, 5xx, and network errors with exponential backoff.
 * Other errors (4xx, tool errors) are thrown immediately.
 * @template T
 * @param {() => Promise<T>} fn
 * @param {{ retries?: number, baseMs?: number, wait?: (ms: number) => Promise<void> }} [opts]
 * @returns {Promise<T>}
 */
export async function withRetry(fn, { retries = 3, baseMs = 1000, wait = sleep } = {}) {
  for (let attempt = 0; ; attempt++) {
    try {
      return await fn();
    } catch (err) {
      const status = err instanceof HttpError ? err.status : null;
      const retryable = status == null ? err instanceof TypeError : status === 429 || status >= 500;
      if (!retryable || attempt >= retries) throw err;
      const hinted = err instanceof HttpError ? err.retryAfterMs : null;
      await wait(hinted ?? baseMs * 2 ** attempt);
    }
  }
}