{"index":5,"slug":"editorial-2027-11-mechanism-mistakes-revisions","title":"Retry без шторма: как повторять HTTP-запросы безопасно","excerpt":"Повтор запроса переживает временный сбой только после проверки семантики операции, общего deadline и способа узнать результат записи. Разбираем backoff, jitter и безопасный отрицательный путь.","contentHtml":"
Симптом знакомый: upstream отвечает 503 или 429, клиент ждёт timeout и отправляет тот же запрос снова. Если так делают тысячи клиентов, восстановление превращается во вторую волну нагрузки. Растут очередь и latency, а здоровые запросы получают меньше ресурсов. Для POST цена ошибки выше: сервер мог сохранить заказ или платёж до разрыва соединения, а повтор создаст второй объект.
\nГлавный вопрос — не «сколько раз повторить», а «можно ли повторить именно эту операцию и как узнать результат первой попытки». Retry безопасен только при двух условиях: повторяемый эффект известен, а задержка подчиняется общему deadline. Backoff снижает частоту запросов, но не исправляет неверную семантику. Idempotency key уменьшает риск дубля, но не отменяет таймаут, лимит попыток или проверку состояния.
\nУ клиента есть два независимых решения. Сначала он определяет право на повтор, затем — момент следующей попытки. HTTP-метод даёт полезную подсказку, но не заменяет контракт endpoint. По RFC 9110, GET, PUT и DELETE относятся к idempotent methods: несколько одинаковых запросов должны иметь тот же намеренный эффект, что и один. Это не запрещает серверу отдельно логировать каждый запрос. POST по умолчанию не даёт такой гарантии.
\nПри timeout результат становится неизвестным. Тело могло дойти до сервера, обработка могла завершиться, а ответ потерялся на обратном пути. Поэтому повтор POST — это не «продолжение» первой попытки, а новая команда. Без idempotency key, статусного endpoint или другого доказуемого механизма дедупликации клиент не знает, создаст ли дубль. В этом случае правильный результат — «состояние неизвестно», а не ещё один POST.
\nСтатус тоже нужно читать как сигнал, а не как разрешение. 429 означает ограничение частоты и может сопровождаться Retry-After. 503 обозначает временную недоступность или перегрузку; сервер также может подсказать время ожидания. 400, 401 и 403 обычно требуют исправить запрос или права, поэтому повтор без изменения входа не помогает. Сетевой timeout вообще не является HTTP-статусом: дополнительно выясните, успел ли транспорт отправить запрос.
| Сигнал | Что известно | Проверка | Решение |
|---|---|---|---|
429 с Retry-After | Сработал rate limit | Прочитать заголовок и область лимита | Ждать не меньше указанного времени; повторять только воспроизводимую операцию |
| 503 | Сервис временно недоступен или перегружен | Проверить deadline и счётчик попыток | Ограниченный backoff с jitter; остановиться при исчерпании бюджета |
| Timeout GET | Ответ потерян, ресурс мог измениться | Сделать чтение состояния и проверить остаток времени | Повторить чтение, если остаётся deadline |
| Timeout POST | Запись могла завершиться | Проверить ключ идемпотентности или статус операции | Не отправлять второй POST без защиты |
| 400, 401, 403 | Вход или права не подходят | Посмотреть тело ответа и контекст авторизации | Не повторять автоматически |
Экспоненциальный backoff увеличивает паузу по номеру попытки: min(cap, base × 2^attempt). cap ограничивает рост задержки, но одинаковая формула всё равно синхронизирует клиентов: после общего сбоя они проснутся почти одновременно. Jitter — случайное смещение — распределяет отправку по интервалу. Вариант full jitter выбирает случайную задержку от нуля до рассчитанного backoff; другой вариант добавляет небольшое смещение к базовой паузе. Выбор зависит от нагрузки и клиента.
Серверный Retry-After задаёт минимальное ожидание. Если он есть, клиент не должен отправлять запрос раньше этого момента, но обязан сравнить его с собственным deadline. Некорректное или чрезмерное значение не должно заставить клиент ждать бесконечно. Deadline принадлежит всей операции: каждой попытке передаётся остаток времени, а не новый полный timeout.
Схема ниже показывает границу ответственности. Клиент сначала классифицирует эффект и сигнал, затем выбирает защиту записи, и только потом рассчитывает паузу. Если любой из этих шагов не дал доказательства безопасности, поток заканчивается проверкой состояния или контролируемой ошибкой.
\nФункция ниже не выполняет HTTP-запрос. Она принимает уже наблюдаемый сигнал и возвращает решение: повторять ли операцию и сколько ждать. Случайность передаётся через randomUnit, поэтому тест получает воспроизводимый вход. В production это значение выдаёт генератор случайных чисел, а итоговая задержка всё равно проходит проверку deadline.
function planRetry({\n method,\n status = null,\n transportError = false,\n attempt,\n nowMs,\n deadlineMs,\n baseMs = 100,\n capMs = 2000,\n maxAttempts = 3,\n randomUnit = 0.5,\n retryAfterMs = null,\n idempotencyKey = false,\n statusEndpoint = false,\n}) {\n const idempotentMethod =\n ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE'].includes(method);\n const repeatable = idempotentMethod || idempotencyKey || statusEndpoint;\n const transient = transportError || status === 429 || status === 503;\n\n if (!repeatable) return { retry: false, reason: 'unknown-result' };\n if (!transient) return { retry: false, reason: 'not-transient' };\n if (!Number.isInteger(attempt) || attempt < 0 ||\n !Number.isInteger(maxAttempts) || maxAttempts < 1) {\n return { retry: false, reason: 'invalid-attempt' };\n }\n if (attempt >= maxAttempts) {\n return { retry: false, reason: 'max-attempts' };\n }\n if (![nowMs, deadlineMs, baseMs, capMs, randomUnit].every(Number.isFinite) ||\n baseMs < 0 || capMs < baseMs) {\n return { retry: false, reason: 'invalid-delay' };\n }\n if (retryAfterMs != null && (!Number.isFinite(retryAfterMs) || retryAfterMs < 0)) {\n return { retry: false, reason: 'invalid-retry-after' };\n }\n if (nowMs >= deadlineMs) {\n return { retry: false, reason: 'deadline-exhausted' };\n }\n\n const backoffMs = Math.min(capMs, baseMs * (2 ** attempt));\n const jitterMs = Math.floor(backoffMs * Math.min(1, Math.max(0, randomUnit)));\n const delayMs = retryAfterMs == null\n ? jitterMs\n : Math.max(jitterMs, retryAfterMs);\n\n if (nowMs + delayMs >= deadlineMs) {\n return { retry: false, reason: 'deadline-exhausted' };\n }\n return { retry: true, delayMs, backoffMs };\n}\n\nconst decision = planRetry({\n method: 'POST', status: 503, attempt: 2,\n nowMs: 1000, deadlineMs: 3000, randomUnit: 0.5,\n idempotencyKey: true,\n});\n// { retry: true, delayMs: 200, backoffMs: 400 }\nВ примере POST повторяется не из-за одного статуса 503, а потому что передан ключ идемпотентности. При попытке с индексом 2 backoff равен 400 мс, а половина случайного диапазона даёт 200 мс. Эти числа учебные: их нельзя переносить в production без знания лимитов upstream, размера пула, допустимой задержки и общего бюджета операции.
Request ID связывает попытки в логах, но сам по себе не заставляет сервер считать их одной операцией. Ключ идемпотентности должен быть частью контракта endpoint. В одном из распространённых вариантов сервер сохраняет ключ, параметры и результат первой обработки, а повтор с тем же ключом возвращает тот же результат. Тот же ключ с другим содержимым нужно отклонять. Это проектное правило сервиса, а не универсальное свойство HTTP.
\nОговорите срок хранения ключа, область уникальности и поведение при параллельных запросах. Если ключ удалили раньше повторной попытки, сервер может принять её как новую. Если запись результата и запись ключа не согласованы, окно дубля останется. Поэтому после неизвестного ответа может понадобиться reconciliation — сверка состояния с источником истины. Ключ снижает риск, но не обещает успешный исход.
\nbase, cap и максимум попыток. Отдельно решите, что делать с Retry-After, отсутствующим и некорректным заголовком.Метод не описывает побочные эффекты конкретной реализации. GET может запускать плохо спроектированную команду, а POST может быть повторяемым по отдельному контракту. Проверяйте endpoint и его хранилище, а не только глагол.
\nRetry не заменяет rate limit, circuit breaker, очередь, bulkhead или backpressure. Они ограничивают разные части системы и не дают разрешения повторить неизвестную запись. Код выше не учитывает конкретный transport, прокси, DNS, HTTP/2, балансировщик и политику upstream. В нём нет production-замеров и универсальных значений задержки.
\nПолитика готова, если для каждого endpoint команда может ответить на четыре вопроса: какой эффект повторяется, почему он безопасен, сколько времени и попыток разрешено, и что делать при неизвестном результате. Тест должен показать, что 429 ждёт Retry-After, 503 не синхронизирует клиентов, timeout POST не создаёт дубль без защиты, а исчерпание deadline останавливает цикл.
Retry-After и 503.Retry-After.