Skip to content
markpaper

src/transport/retry.ts

v0.3.0 · 3.9 KB

Download file
// Retry with exponential backoff and jitter.
//
// Default profile: 4 retries, 250 ms x 2.5^n, jitter +-40%
// (about 250 / 625 / 1560 / 3900 ms, ~6.3 s in total). Jitter matters more than the
// exponent: it spreads a burst of N parallel callers that failed together, which is the
// main relief against IP-level 429s. Every attempt must go back through the limiter.

/** Backoff parameters. */
export interface BackoffOptions {
  /** Delay before the first retry, ms. Default 250. */
  baseDelayMs?: number;
  /** Multiplier per attempt. Default 2.5. */
  factor?: number;
  /** Relative jitter, 0.4 = +-40%. Default 0.4. */
  jitter?: number;
  /** Upper bound for a single delay, ms. Default: no cap. */
  maxDelayMs?: number;
  /** Random source in [0, 1). Default `Math.random`. */
  random?: () => number;
}

/** Retry parameters (backoff + attempt budget). */
export interface RetryOptions extends BackoffOptions {
  /** Retries after the first attempt. Default 4 (5 attempts in total). */
  retries?: number;
}

export const DEFAULT_RETRY = Object.freeze({
  retries: 4,
  baseDelayMs: 250,
  factor: 2.5,
  jitter: 0.4,
});

/**
 * Delay before retry number `attempt` (0-based): `base * factor^attempt * (1 +- jitter)`, rounded,
 * never negative, capped by `maxDelayMs`.
 */
export function backoffDelayMs(attempt: number, opts: BackoffOptions = {}): number {
  const base = opts.baseDelayMs ?? DEFAULT_RETRY.baseDelayMs;
  const factor = opts.factor ?? DEFAULT_RETRY.factor;
  const jitter = Math.min(1, Math.max(0, opts.jitter ?? DEFAULT_RETRY.jitter));
  const random = opts.random ?? Math.random;
  const n = Math.max(0, Math.floor(attempt));
  const raw = base * factor ** n * (1 + jitter * (random() * 2 - 1));
  const capped = opts.maxDelayMs !== undefined ? Math.min(opts.maxDelayMs, raw) : raw;
  return Math.max(0, Math.round(capped));
}

/** Options for {@link withRetry}. */
export interface WithRetryOptions extends RetryOptions {
  /** Decides whether an error is retryable. Pick the predicate by action class (see `limitExchange`). */
  shouldRetry: (err: unknown, attempt: number) => boolean;
  /** Aborts the backoff sleep; an aborted signal also stops further retries. */
  signal?: AbortSignal;
  /** Called before sleeping for a retry. */
  onRetry?: (err: unknown, attempt: number, delayMs: number) => void;
}

function abortReason(signal: AbortSignal): unknown {
  return signal.reason ?? new DOMException('This operation was aborted', 'AbortError');
}

/** Promise-based sleep that rejects with the signal's reason when aborted. */
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
  if (signal?.aborted) return Promise.reject(abortReason(signal));
  return new Promise<void>((resolve, reject) => {
    const onAbort = () => {
      clearTimeout(timer);
      reject(abortReason(signal as AbortSignal));
    };
    const timer = setTimeout(() => {
      signal?.removeEventListener('abort', onAbort);
      resolve();
    }, ms);
    signal?.addEventListener('abort', onAbort, { once: true });
  });
}

/**
 * Runs `fn` and retries it while `shouldRetry` approves, sleeping {@link backoffDelayMs} between
 * attempts. Throws the last error once the budget is spent. `fn` receives the 0-based attempt.
 *
 * Retrying is a policy decision: /info is idempotent and may retry on 429/5xx/network, while an
 * opening order may only retry on 429 (a retry after an ambiguous 5xx doubles the position).
 */
export async function withRetry<T>(fn: (attempt: number) => Promise<T>, opts: WithRetryOptions): Promise<T> {
  const retries = Math.max(0, Math.floor(opts.retries ?? DEFAULT_RETRY.retries));
  for (let attempt = 0; ; attempt++) {
    try {
      return await fn(attempt);
    } catch (err) {
      if (opts.signal?.aborted) throw err;
      if (attempt >= retries || !opts.shouldRetry(err, attempt)) throw err;
      const delay = backoffDelayMs(attempt, opts);
      opts.onRetry?.(err, attempt, delay);
      await sleep(delay, opts.signal);
    }
  }
}
All files