DarkRiDDeR15 мин

Retry без шторма: backoff, jitter и идемпотентность

HTTPНадёжность

Проблема видна как лавина: один upstream отвечает 503 или 429, а несколько клиентов одновременно повторяют запрос. Цена — усилить перегрузку именно в момент восстановления, увеличить latency и получить каскад отказов. Без различия между безопасным чтением и записью retry превращается в генератор дублей.

Причина — считать повтор одной настройкой «три попытки». Правильное решение зависит от HTTP-метода, кода ответа, идемпотентности операции, Retry-After, текущего deadline и нагрузки. Экспоненциальная задержка уменьшает частоту, а jitter раздвигает одинаковые моменты старта; ни одна из них не делает небезопасную запись безопасной.

Сначала право на повтор

GET обычно проектируют как идемпотентное чтение, но серверная реализация и внешние побочные эффекты всё равно требуют проверки. POST может создать ресурс повторно. Для такой операции нужен idempotency key и серверное хранение результата, либо клиент должен получить способ запросить состояние операции. Timeout означает неизвестный результат, а не подтверждённый отказ.

Статус 429 сообщает о частоте запросов, но не выбирает за клиента точный алгоритм. 503 может означать временную недоступность, но повтор с коротким интервалом усугубит проблему. 400 обычно не меняется от повтора. Политика должна принимать method, status, наличие Retry-After и остаток deadline, а не только boolean «network error».

Решение о повторе
СигналПовторЗадержкаРиск
429 + Retry-Afterтолько если операция допустимане раньше указанного времениклиенты проснутся одновременно
503 без Retry-Afterограниченноbackoff + jitterперегрузить восстанавливающийся upstream
timeout GETвозможеностаток deadlineответ мог быть готов на сервере
timeout POSTтолько с ключом/проверкойкороткий controlled retryдублирование записи
400/401/403нетне нужнаповтор маскирует дефект входа или права

Экспонента не должна расти бесконечно

Базовая формула min(cap, base × 2^attempt) ограничивает задержку сверху. Jitter добавляет случайное смещение, чтобы тысячи клиентов не повторили в одну миллисекунду. Но общий deadline должен иметь приоритет: если до его конца осталось 40 мс, не имеет смысла ждать 500 мс ради следующей попытки. Операция завершается, а причина сохраняется.

Случайность нужно добавлять так, чтобы наблюдение оставалось возможным. Логируйте номер попытки, рассчитанную задержку, статус и остаток deadline, но не секреты и полное тело запроса. Для тестов используйте переданный генератор случайных чисел или фиксированный jitter. Иначе тест будет иногда падать, а реальная политика останется непроверяемой.

Матрица retry: метод и статус задают право на повтор, затем применяются deadline, backoff и jitter; неизвестный результат записи требует идемпотентного ключа.
Схема отделяет решение о повторе от расчёта задержки. Красная граница означает, что backoff не исправляет небезопасную семантику операции.

Runnable-пример: рассчитать ограниченную задержку

Функция получает номер попытки, базовую задержку, cap и учебный jitter. Она возвращает экспоненциальную часть и итог. В реальном клиенте jitter обычно генерируется отдельно и учитывается в deadline; здесь он передан числом, чтобы результат был воспроизводимым. На третьей попытке с base 100 и cap 1000 экспонента равна 800.

import { calculateRetryDelay } from './upgrade-2027-11.mjs';

const third = calculateRetryDelay({
  attempt: 3,
  baseMs: 100,
  capMs: 1000,
  jitterMs: 37,
});
const invalid = calculateRetryDelay({
  attempt: -1,
  baseMs: 100,
  capMs: 1000,
});

console.log(third.ok, third.exponentialMs, third.delayMs);
console.log(invalid.ok, invalid.reason);
// true 800 837
// false retry-input-invalid

Порядок настройки retry

  1. Для каждого метода выпишите побочные эффекты и способ узнать результат после timeout. Без этого число попыток не имеет смысла.
  2. Составьте таблицу статусов: что можно повторять, какой сигнал приходит от сервера и когда нужно остановиться.
  3. Передавайте абсолютный deadline или остаток времени во все попытки. Не выдавайте каждой попытке новый полный бюджет.
  4. Задайте base, cap и предел попыток, затем добавьте jitter. Проверьте формулу на нулевой, первой и предельной попытке.
  5. Обработайте Retry-After как верхнюю границу политики сервера, но всё равно сравните её с deadline.
  6. Нагрузочным тестом проверьте восстановление upstream: retry не должен создавать вторую волну запросов быстрее исходной.

Идемпотентный ключ — это не request-id

Request-id помогает найти попытку в логах, но сам по себе не говорит серверу, что две попытки означают одну операцию. Idempotency key должен быть связан с семантикой команды, сроком хранения и результатом. Сервер обязан решить, что вернуть при повторе с тем же ключом и другим телом. Это часть контракта, а не случайное поле заголовка.

Даже при ключе остаются границы: сбой между записью и сохранением результата, истечение TTL, разные пользователи и смена версии схемы. Поэтому ключ не отменяет тесты повторной доставки и проверку состояния. Он даёт серверу возможность дедуплицировать операцию, но не обещает успешный outcome.

Ограничения и следующий шаг

Расчёт задержки не реализует случайный генератор, circuit breaker, rate limit и очередь. RFC 6585 описывает статус 429, но не определяет вашу политику. Примеры чисел учебные и не подходят для копирования без измерения downstream и общего deadline.

Следующий шаг — выбрать один endpoint, описать повтор для каждого метода и прогнать искусственный 429/503 с фиксированным временем. Отдельно проверьте timeout POST: повтор должен либо использовать idempotency key, либо перейти к запросу статуса, а не автоматически создать вторую запись.

Проверяемые источники

  • RFC 9110 — HTTP Semantics — IETF, июнь 2022 года, RFC 9110, Standards Track. Применение: Разделяет свойства методов и идемпотентность, необходимые для решения, допустим ли повтор запроса. Граница: Не задаёт политику retry конкретного клиента, backoff и максимальное число попыток.
  • RFC 6585 — Additional HTTP Status Codes — IETF, апрель 2012 года, RFC 6585, Standards Track. Применение: Фиксирует статус 429 Too Many Requests и место сигнала о перегрузке сервера. Граница: Не говорит, какой delay выбрать и можно ли повторять конкретную операцию.