← See all notes

Table of Contents

TypeScript

FetchWithBackoff 🔗

interface BackoffConfig {
  delays?: number[];
  jitterMin?: number;
  jitterMax?: number;
  retryCondition?: (error: Error | null, response: Response | null) => boolean;
  signal?: AbortSignal;
}

class FetchWithBackoff {
  private delays: number[];
  private jitterMin: number;
  private jitterMax: number;
  private retryCondition: (error: Error | null, response: Response | null) => boolean;

  constructor(config: BackoffConfig = {}) {
    this.delays = config.delays || [
      1000,
      2000,
      4000,
      8000,
      16000,
      32000,
      60000,
      60000,
      60000,
      60000,
    ];

    this.jitterMin = config.jitterMin ?? 0.75;
    this.jitterMax = config.jitterMax ?? 1.25;
    this.retryCondition = config.retryCondition || this.defaultRetryCondition;
  }

  private defaultRetryCondition(error: Error | null, response: Response | null): boolean {
    // Retry on network errors
    if (error && !response) return true;

    if (response) {
      return response.status >= 500 || response.status === 429;
    }

    return false;
  }

  private applyJitter(delay: number): number {
    const jitterRange = this.jitterMax - this.jitterMin;
    const jitterFactor = this.jitterMin + Math.random() * jitterRange;
    return Math.round(delay * jitterFactor);
  }

  async fetch(url: string, options: RequestInit = {}): Promise<Response> {
    let lastError: Error | null = null;
    let lastResponse: Response | null = null;

    for (let i = 0; i < this.delays.length; i++) {
      const isLastAttempt = i === this.delays.length - 1;

      try {
        if (options.signal?.aborted) {
          throw new DOMException('Aborted', 'AbortError');
        }

        const response = await fetch(url, options);

        if (response.ok || !this.retryCondition(null, response)) {
          return response;
        }

        lastResponse = response;
        lastError = new Error(`HTTP ${response.status}: ${response.statusText}`);
      } catch (error) {
        lastError = error as Error;
        lastResponse = null;

        if (!this.retryCondition(lastError, null)) {
          throw lastError;
        }
      }

      if (isLastAttempt) {
        throw lastError || new Error('Unknown error occurred');
      }

      const baseDelay = this.delays[i];
      const jitteredDelay = this.applyJitter(baseDelay);

      console.log(`Attempt ${i + 1} failed. Retrying in ${jitteredDelay}ms...`);

      await new Promise<void>((resolve, reject) => {
        const timeoutId = setTimeout(resolve, jitteredDelay);

        const onAbort = () => {
          clearTimeout(timeoutId);
          reject(new DOMException('Aborted', 'AbortError'));
        };

        if (options.signal) {
          if (options.signal.aborted) {
            clearTimeout(timeoutId);
            reject(new DOMException('Aborted', 'AbortError'));
            return;
          }
          options.signal.addEventListener('abort', onAbort, { once: true });

          setTimeout(() => {
            options.signal?.removeEventListener('abort', onAbort);
          }, jitteredDelay);
        }
      });
    }

    throw new Error(`Failed after ${this.delays.length} attempts`);
  }
}

Chrome Extensions 🔗