{ "index": 5, "slug": "editorial-2027-11-mechanism-mistakes-revisions", "title": "Retry без шторма: как повторять HTTP-запросы безопасно", "excerpt": "Повтор запроса помогает пережить временный сбой только после проверки идемпотентности, статуса, Retry-After и общего deadline. Разбираем backoff, jitter и отрицательный путь для записей.", "contentHtml": "
Симптом обычно выглядит просто: upstream отвечает 503 или 429, клиент ждёт timeout, а затем несколько раз отправляет тот же запрос. Если таких клиентов много, восстановление превращается во вторую волну нагрузки. Растут latency и очередь, здоровые запросы получают меньше ресурсов. Для POST цена выше: сервер мог принять запись до разрыва соединения, а повтор может создать второй заказ, платёж или задачу.
\nТезис статьи короткий: retry — это решение о семантике, а не число попыток в конфиге. Сначала нужно понять, можно ли безопасно повторить операцию и как узнать результат первой попытки. Только после этого выбирают статус, задержку, jitter и предел. Backoff не делает небезопасную запись безопасной. Idempotency key не отменяет deadline и не заменяет проверку состояния.
\nУ клиента есть два независимых вопроса. Первый: разрешён ли повтор. Второй: когда его отправить. Код ответа и HTTP-метод помогают ответить на первый вопрос, но не описывают всю бизнес-семантику. GET обычно читает ресурс. PUT и DELETE относятся к идемпотентным методам по смыслу HTTP: повтор не должен менять запрошенный эффект. POST часто создаёт новую операцию, поэтому таймаут оставляет результат неизвестным.
\nНеизвестный результат важнее слова «ошибка». Клиент мог отправить тело, сервер мог записать данные, а ответ мог потеряться при обратной передаче. В этом случае повтор — не восстановление связи, а новая попытка выполнить команду. Для записи нужен один из трёх путей: idempotency key с серверной дедупликацией, запрос статуса операции или контракт, который делает повтор фактически идемпотентным. Если ни одного пути нет, автоматический retry должен закончиться отказом с сохранением диагностического контекста.
\nКоды 429 и 503 тоже не дают универсального разрешения. 429 означает ограничение частоты; ответ может содержать Retry-After. 503 часто указывает на временную недоступность, но повтор без лимита способен продлить перегрузку. 400, 401 и 403 обычно требуют исправить входные данные или права. Повтор не изменит причину. Сетевой timeout не является HTTP-статусом и требует отдельно оценить, была ли операция отправлена и могла ли она завершиться.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| 429 с Retry-After | Сработал лимит частоты | Прочитать заголовок и область лимита | Повторить только идемпотентную операцию, не раньше разрешённого времени |
| 503 без Retry-After | Upstream временно недоступен или перегружен | Сверить статус, deadline и счётчик попыток | Применить ограниченный backoff с jitter |
| Timeout GET | Ответ потерян или сервер ещё работает | Повторно прочитать ресурс и проверить остаток deadline | Повторить чтение при наличии бюджета |
| Timeout POST | Запись могла завершиться | Проверить idempotency key или статус операции | Не отправлять второй POST без защиты |
| 400, 401 или 403 | Неверные данные или права | Посмотреть тело ответа и авторизацию | Не повторять автоматически |
Экспоненциальный backoff снижает частоту повторов по мере роста номера попытки. Базовая формула: min(cap, base * 2^attempt). Параметр cap не даёт задержке расти бесконечно. Но один backoff не решает проблему синхронизации. Если тысячи клиентов получили сбой в одном интервале, одинаковая формула разбудит их почти одновременно.
Jitter добавляет случайное смещение. В простом варианте клиент выбирает задержку в диапазоне от нуля до рассчитанного значения. В другом варианте он добавляет небольшой случайный интервал к deterministic backoff. Выбор варианта зависит от клиента и нагрузки. Важно не смешать случайность с бесконтрольным ожиданием: итоговая задержка всё равно должна укладываться в общий deadline и максимальное число попыток.
\nDeadline принадлежит всей операции. Нельзя выдавать каждой попытке новый полный timeout. Если у операции осталось 120 миллисекунд, а рассчитанный backoff равен 500 миллисекундам, нужно завершить операцию или перейти к проверке состояния. Иначе локальный retry будет скрывать задержку от вызывающего кода и увеличивать очередь. В журналах сохраняйте номер попытки, статус, задержку, остаток deadline и причину остановки. Не записывайте секреты и полное тело запроса.
\nНиже — учебная функция для расчёта задержки. Она не выполняет HTTP-запрос, не генерирует случайность и не знает, разрешён ли retry для конкретного метода. Jitter передаётся числом, чтобы пример имел воспроизводимый результат. В реальном клиенте случайное значение нужно получать через управляемый генератор и сравнивать итог с остатком deadline.
\nfunction retryDelay(attempt, baseMs, capMs, jitterMs) {\n if (!Number.isInteger(attempt) || attempt < 0) {\n return { ok: false, reason: 'invalid-attempt' };\n }\n if (![baseMs, capMs, jitterMs].every(Number.isFinite)) {\n return { ok: false, reason: 'invalid-delay' };\n }\n if (baseMs < 0 || capMs < baseMs || jitterMs < 0) {\n return { ok: false, reason: 'invalid-delay-range' };\n }\n\n const exponentialMs = Math.min(capMs, baseMs * (2 ** attempt));\n return {\n ok: true,\n exponentialMs,\n delayMs: exponentialMs + jitterMs,\n };\n}\n\nconst result = retryDelay(3, 100, 1000, 37);\n// { ok: true, exponentialMs: 800, delayMs: 837 }\nЧисла в примере учебные. На попытке с индексом 3 экспоненциальная часть равна 800 миллисекундам, потому что 100 * 2^3 не превышает cap 1000. Этот расчёт не доказывает, что задержка подходит вашему upstream. Для подбора параметров нужны его лимиты, общий deadline, размер пула и допустимая нагрузка. Если jitter добавить после проверки deadline, клиент всё равно может превысить бюджет, поэтому проверку выполняют для итоговой задержки.
Request ID связывает попытки в логах. Он не заставляет сервер считать их одной операцией. Idempotency key должен входить в контракт endpoint. Сервер хранит ключ вместе с идентификатором операции и результатом в течение согласованного времени. При повторе с тем же ключом и тем же содержимым он возвращает тот же результат или текущее состояние. При другом содержимом сервер должен отклонить запрос, а не молча перезаписать первую операцию.
\nУ ключа есть ограничения. Он может истечь до того, как клиент повторит запрос. Сбой может произойти между записью результата и сохранением записи о ключе. Разные пользователи могут случайно выбрать одинаковый ключ, если сервер не включает владельца в область уникальности. При смене версии схемы может потребоваться отдельная совместимость. Поэтому ключ снижает риск дубля, но не обещает успешный исход и не заменяет reconciliation.
\nОтрицательный путь должен быть явным. Если POST завершился timeout, нет ключа и нет API статуса, клиент не повторяет его автоматически. Он возвращает состояние «результат неизвестен», сохраняет correlation id и предлагает безопасную проверку. Если upstream отвечает 400, клиент исправляет запрос или показывает ошибку. Если deadline закончился во время backoff, клиент останавливается. Повтор ради заполнения метрики успешных ответов не является корректным действием.
\nHTTP-метод не описывает побочные эффекты конкретного сервиса. GET может запускать плохо спроектированную команду, а POST может быть идемпотентным по отдельному контракту. Проверяйте реализацию endpoint, а не только название метода.
\nRetry не заменяет circuit breaker, rate limit, очередь, bulkhead и backpressure. Эти механизмы решают разные задачи. Circuit breaker ограничивает вызовы при устойчивом сбое. Rate limit распределяет бюджет запросов. Очередь меняет момент выполнения. Ни один из них не разрешает повторить неизвестную запись без проверки семантики.
\nТаблица и код выше не являются готовой библиотекой. Они не учитывают конкретный transport, прокси, DNS, HTTP/2, балансировщик и политику upstream. Пример не содержит production-замеров и не заявляет универсальные значения base, cap или jitter. Эти параметры нужно подтвердить тестом на вашей границе нагрузки.
\nПолитика готова, если для каждого endpoint можно ответить на четыре вопроса: какую операцию повторяем, почему она безопасна, сколько времени и попыток разрешено, и что делаем при неизвестном результате. Тест должен показать, что 429 учитывает Retry-After, 503 не создаёт синхронную вторую волну, timeout POST не создаёт дубль без ключа, а исчерпание deadline останавливает цикл. Если хотя бы один ответ звучит как «повторяем всегда», политика не готова.
\n