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..."
}
}Что мы добавили:
- Параметр factor: Позволяет гибко настраивать скорость роста задержки.
- Проброс сигнала в задачу: Теперь task получает signal, что позволяет отменять сам сетевой запрос, а не только ожидание между ними.
- Информативный yield: Теперь возвращаем объект с номером попытки и временем следующего ожидания для удобного логирования.