Retry с экспоненциальным backoff

Экспоненциальный backoff — стратегия повторных запросов, при которой каждая следующая попытка ждёт вдвое дольше предыдущей, снижая нагрузку на перегруженный сервер.

Зачем нужно

Простой retry в цикле без ожидания при сбое сервера приводит к «шторму запросов»: все клиенты одновременно повторяют запросы, перегружая и без того нестабильный сервис. Экспоненциальный backoff + jitter (случайный разброс) равномерно распределяет повторные запросы во времени и даёт серверу возможность восстановиться.

Где используется

  • HTTP-клиенты, обращающиеся к внешним API
  • Подключение к базам данных и брокерам сообщений при старте сервиса
  • Паттерн Circuit Breaker (в дополнение к backoff)
  • AWS SDK, Google Cloud SDK — backoff встроен по умолчанию

Алгоритм

Попытка 1: немедленно
Попытка 2: ждать base * 2^0 = base мс
Попытка 3: ждать base * 2^1 = base*2 мс
Попытка 4: ждать base * 2^2 = base*4 мс
...
Попытка N: ждать min(base * 2^(N-2), maxDelay) мс

+ jitter: ± random(0, delay * 0.25) — разброс между клиентами

Реализация

async function fetchWithRetry(url, options = {}, {
  maxRetries = 3,
  baseDelay = 500,    // мс
  maxDelay = 10_000,  // мс
  retryOn = [429, 502, 503, 504],
} = {}) {
  let lastError;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const res = await fetch(url, options);

      // Успех или клиентская ошибка (4xx кроме 429) — не повторяем
      if (res.ok || (res.status < 500 && res.status !== 429)) {
        return res;
      }

      // Серверная ошибка или rate limit — повторяем
      if (!retryOn.includes(res.status)) return res;

      lastError = new Error(`HTTP ${res.status}`);

      // Читаем Retry-After из заголовка (приоритет над backoff)
      const retryAfter = res.headers.get('Retry-After');
      if (retryAfter) {
        await sleep(parseInt(retryAfter) * 1000);
        continue;
      }
    } catch (err) {
      // Сетевая ошибка (нет соединения, таймаут)
      lastError = err;
    }

    if (attempt < maxRetries) {
      const delay = calcDelay(attempt, baseDelay, maxDelay);
      console.warn(`Retry ${attempt + 1}/${maxRetries} after ${delay}ms`);
      await sleep(delay);
    }
  }

  throw lastError;
}

function calcDelay(attempt, base, max) {
  const exponential = base * Math.pow(2, attempt);
  const capped = Math.min(exponential, max);
  // Jitter: ±25% случайного разброса
  const jitter = capped * 0.25 * (Math.random * 2 - 1);
  return Math.round(capped + jitter);
}

const sleep = ms => new Promise(r => setTimeout(r, ms));

// Использование
const res = await fetchWithRetry('/api/orders', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ items: [...] }),
});

Идемпотентность и retry

// Безопасно повторять: GET, HEAD, DELETE, PUT (идемпотентные)
// Опасно повторять: POST — может создать дубликаты

// Защита от дублей при retry POST: Idempotency-Key
const idempotencyKey = crypto.randomUUID;
const res = await fetchWithRetry('/api/payments', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Idempotency-Key': idempotencyKey, // сервер дедуплицирует по ключу
  },
  body: JSON.stringify({ amount: 1000 }),
});

Частые ошибки

  • Retry без ожидания — «шторм запросов» при падении сервера
  • Одинаковый delay для всех клиентов без jitter — синхронные волны запросов
  • Retry на 4xx ошибки (кроме 429) — повторный запрос с теми же данными не поможет
  • POST-retry без Idempotency-Key — двойные платежи, дублирование заказов
  • Бесконечный retry без maxRetries — зависание приложения

Связанные темы

Ресурсы