v1: Базовый вариант

type RetryTask<T> = () => Promise<T>;

interface RetryOptions {
  maxRetries: number;
  delay: number; // задержка в мс
}

class Retrier {
  constructor(private options: RetryOptions) {}

  /**
   * Выполняет задачу с повторами. 
   * Генерирует (yield) ошибку при каждой неудачной попытке.
   * Возвращает результат при успехе.
   */
  async *execute<T>(task: RetryTask<T>, signal?: AbortSignal): AsyncGenerator<unknown, T, void> {
    for (let attempt = 1; attempt <= this.options.maxRetries; attempt++) {
      // Проверка на отмену перед началом попытки
      if (signal?.aborted) throw new Error('Operation aborted');

      try {
        return await task();
      } catch (error) {
        // Если попытки исчерпаны или пришла отмена — пробрасываем ошибку дальше
        if (attempt === this.options.maxRetries || signal?.aborted) {
          throw error;
        }

        // Сообщаем вызывающему коду об ошибке в текущей итерации
        yield error;

        // Ожидание перед следующей попыткой с поддержкой прерывания
        await new Promise((resolve, reject) => {
          const timeout = setTimeout(resolve, this.options.delay);
          signal?.addEventListener('abort', () => {
            clearTimeout(timeout);
            reject(new Error('Operation aborted during delay'));
          }, { once: true });
        });
      }
    }
    throw new Error('Max retries reached');
  }
}

// Паттерн Фабрика для создания экземпляров ретраера
const RetrierFactory = {
  create(options: RetryOptions): Retrier {
    return new Retrier(options);
  }
};

Как это использовать

Использование генератора позволяет гибко логировать ошибки каждой попытки прямо в цикле for await...of.

async function runWithCancel() {
  const controller = new AbortController();
  const retrier = RetrierFactory.create({ maxRetries: 3, delay: 1000 });

  // Пример задачи: запрос к API
  const fetchData = () => fetch('https://api.example.com').then(r => r.json());

  try {
    const generator = retrier.execute(fetchData, controller.signal);

    for await (const error of generator) {
      console.warn('Попытка не удалась, пробуем снова...', error);
      // Здесь можно вызвать controller.abort(), если решили прекратить попытки
    }

    // Если цикл завершился без ошибки, результат возвращается из генератора
    // Но так как `return` в async генераторах требует особого обращения, 
    // результат часто получают через финальный вызов.
  } catch (err) {
    console.error('Финальная ошибка или отмена:', err.message);
  }
}

v2: Вариант с экспоненциальной задержкой

type RetryTask<T> = (signal: AbortSignal) => Promise<T>;

interface RetryOptions {
  maxRetries: number;
  initialDelay: number; // Начальная задержка (мс)
  factor: number;       // Множитель (обычно 2)
}

class Retrier {
  constructor(private options: RetryOptions) {}

  async *execute<T>(task: RetryTask<T>, signal?: AbortSignal): AsyncGenerator<unknown, T, void> {
    let currentDelay = this.options.initialDelay;

    for (let attempt = 1; attempt <= this.options.maxRetries; attempt++) {
      if (signal?.aborted) throw new Error('Aborted');

      try {
        // Передаем signal внутрь задачи, чтобы fetch/axios тоже могли прерваться
        return await task(signal!); 
      } catch (error) {
        if (attempt === this.options.maxRetries || signal?.aborted) throw error;

        yield { attempt, error, nextDelay: currentDelay };

        // Ожидание с поддержкой прерывания
        await new Promise((resolve, reject) => {
          const timeout = setTimeout(resolve, currentDelay);
          signal?.addEventListener('abort', () => {
            clearTimeout(timeout);
            reject(new Error('Aborted during backoff'));
            // NOTE: Это состояние означает, что вы отменили операцию (вызвали controller.abort()) в тот момент, когда запрос уже упал с ошибкой, и ретраер просто ждал паузу перед следующей попыткой
          }, { once: true });
        });

        // Увеличиваем задержку для следующего шага
        currentDelay *= this.options.factor;
      }
    }
    throw new Error('Max retries exceeded');
  }
}

const RetrierFactory = {
  createDefault: () => new Retrier({ maxRetries: 5, initialDelay: 500, factor: 2 }),
  createCustom: (options: RetryOptions) => new Retrier(options)
};

Пример работы задержек

Если initialDelay: 1000 и factor: 2, паузы между попытками будут:

Попытка 1 (ошибка) -> Пауза 1с
Попытка 2 (ошибка) -> Пауза 2с
Попытка 3 (ошибка) -> Пауза 4с

Использование с контролем отмены

async function demo() {
  const controller = new AbortController();
  const retrier = RetrierFactory.createDefault();

  // Имитируем отмену через 3 секунды
  setTimeout(() => controller.abort(), 3000);

  try {
    const task = (sig: AbortSignal) => fetch('https://api.example.com', { signal: sig }).then(res => res.json());
    
    const runner = retrier.execute(task, controller.signal);

    for await (const status of runner) {
      console.log(`Попытка ${status.attempt} не удалась. Ждем ${status.nextDelay}мс...`);
    }
  } catch (e) {
    console.log('Результат:', e.message); // Выведет "Aborted..."
  }
}

Что мы добавили:

  1. Параметр factor: Позволяет гибко настраивать скорость роста задержки.
  2. Проброс сигнала в задачу: Теперь task получает signal, что позволяет отменять сам сетевой запрос, а не только ожидание между ними.
  3. Информативный yield: Теперь возвращаем объект с номером попытки и временем следующего ожидания для удобного логирования.