2 lines
18 KiB
JSON
2 lines
18 KiB
JSON
{"index":5,"slug":"editorial-2027-11-mechanism-mistakes-revisions","title":"Retry без шторма: как повторять HTTP-запросы безопасно","excerpt":"Повтор запроса переживает временный сбой только после проверки семантики операции, общего deadline и способа узнать результат записи. Разбираем backoff, jitter и безопасный отрицательный путь.","contentHtml":"<p>Симптом знакомый: upstream отвечает 503 или 429, клиент ждёт timeout и отправляет тот же запрос снова. Если так делают тысячи клиентов, восстановление превращается во вторую волну нагрузки. Растут очередь и latency, а здоровые запросы получают меньше ресурсов. Для POST цена ошибки выше: сервер мог сохранить заказ или платёж до разрыва соединения, а повтор создаст второй объект.</p>\n<p>Главный вопрос — не «сколько раз повторить», а «можно ли повторить именно эту операцию и как узнать результат первой попытки». Retry безопасен только при двух условиях: повторяемый эффект известен, а задержка подчиняется общему deadline. Backoff снижает частоту запросов, но не исправляет неверную семантику. Idempotency key уменьшает риск дубля, но не отменяет таймаут, лимит попыток или проверку состояния.</p>\n<h2>Что именно ломается</h2>\n<p>У клиента есть два независимых решения. Сначала он определяет право на повтор, затем — момент следующей попытки. HTTP-метод даёт полезную подсказку, но не заменяет контракт endpoint. По RFC 9110, GET, PUT и DELETE относятся к idempotent methods: несколько одинаковых запросов должны иметь тот же намеренный эффект, что и один. Это не запрещает серверу отдельно логировать каждый запрос. POST по умолчанию не даёт такой гарантии.</p>\n<p>При timeout результат становится неизвестным. Тело могло дойти до сервера, обработка могла завершиться, а ответ потерялся на обратном пути. Поэтому повтор POST — это не «продолжение» первой попытки, а новая команда. Без idempotency key, статусного endpoint или другого доказуемого механизма дедупликации клиент не знает, создаст ли дубль. В этом случае правильный результат — «состояние неизвестно», а не ещё один POST.</p>\n<p>Статус тоже нужно читать как сигнал, а не как разрешение. 429 означает ограничение частоты и может сопровождаться <code>Retry-After</code>. 503 обозначает временную недоступность или перегрузку; сервер также может подсказать время ожидания. 400, 401 и 403 обычно требуют исправить запрос или права, поэтому повтор без изменения входа не помогает. Сетевой timeout вообще не является HTTP-статусом: дополнительно выясните, успел ли транспорт отправить запрос.</p>\n<table><caption>Минимальная матрица решения о повторе</caption><thead><tr><th scope=\"col\">Сигнал</th><th scope=\"col\">Что известно</th><th scope=\"col\">Проверка</th><th scope=\"col\">Решение</th></tr></thead><tbody><tr><td>429 с <code>Retry-After</code></td><td>Сработал rate limit</td><td>Прочитать заголовок и область лимита</td><td>Ждать не меньше указанного времени; повторять только воспроизводимую операцию</td></tr><tr><td>503</td><td>Сервис временно недоступен или перегружен</td><td>Проверить deadline и счётчик попыток</td><td>Ограниченный backoff с jitter; остановиться при исчерпании бюджета</td></tr><tr><td>Timeout GET</td><td>Ответ потерян, ресурс мог измениться</td><td>Сделать чтение состояния и проверить остаток времени</td><td>Повторить чтение, если остаётся deadline</td></tr><tr><td>Timeout POST</td><td>Запись могла завершиться</td><td>Проверить ключ идемпотентности или статус операции</td><td>Не отправлять второй POST без защиты</td></tr><tr><td>400, 401, 403</td><td>Вход или права не подходят</td><td>Посмотреть тело ответа и контекст авторизации</td><td>Не повторять автоматически</td></tr></tbody></table>\n<h2>Как не устроить вторую волну</h2>\n<p>Экспоненциальный backoff увеличивает паузу по номеру попытки: <code>min(cap, base × 2^attempt)</code>. <code>cap</code> ограничивает рост задержки, но одинаковая формула всё равно синхронизирует клиентов: после общего сбоя они проснутся почти одновременно. Jitter — случайное смещение — распределяет отправку по интервалу. Вариант full jitter выбирает случайную задержку от нуля до рассчитанного backoff; другой вариант добавляет небольшое смещение к базовой паузе. Выбор зависит от нагрузки и клиента.</p>\n<p>Серверный <code>Retry-After</code> задаёт минимальное ожидание. Если он есть, клиент не должен отправлять запрос раньше этого момента, но обязан сравнить его с собственным deadline. Некорректное или чрезмерное значение не должно заставить клиент ждать бесконечно. Deadline принадлежит всей операции: каждой попытке передаётся остаток времени, а не новый полный timeout.</p>\n<p>Схема ниже показывает границу ответственности. Клиент сначала классифицирует эффект и сигнал, затем выбирает защиту записи, и только потом рассчитывает паузу. Если любой из этих шагов не дал доказательства безопасности, поток заканчивается проверкой состояния или контролируемой ошибкой.</p>\n<figure><img src=\"/assets/editorial/2027/mistakes-revisions-2027-condition-correction-matrix.svg\" alt=\"Поток решения о retry: классификация метода и статуса, проверка идемпотентности, расчёт deadline, backoff и jitter; неизвестный результат записи ведёт к проверке состояния.\" loading=\"lazy\" /><figcaption>Сначала проверяется семантика операции, затем рассчитывается задержка. Deadline и защита записи имеют приоритет над числом попыток.</figcaption></figure>\n<h2>Самодостаточный пример политики</h2>\n<p>Функция ниже не выполняет HTTP-запрос. Она принимает уже наблюдаемый сигнал и возвращает решение: повторять ли операцию и сколько ждать. Случайность передаётся через <code>randomUnit</code>, поэтому тест получает воспроизводимый вход. В production это значение выдаёт генератор случайных чисел, а итоговая задержка всё равно проходит проверку deadline.</p>\n<pre><code>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 }</code></pre>\n<p>В примере <code>POST</code> повторяется не из-за одного статуса 503, а потому что передан ключ идемпотентности. При попытке с индексом 2 backoff равен 400 мс, а половина случайного диапазона даёт 200 мс. Эти числа учебные: их нельзя переносить в production без знания лимитов upstream, размера пула, допустимой задержки и общего бюджета операции.</p>\n<h2>Контракт ключа идемпотентности</h2>\n<p>Request ID связывает попытки в логах, но сам по себе не заставляет сервер считать их одной операцией. Ключ идемпотентности должен быть частью контракта endpoint. В одном из распространённых вариантов сервер сохраняет ключ, параметры и результат первой обработки, а повтор с тем же ключом возвращает тот же результат. Тот же ключ с другим содержимым нужно отклонять. Это проектное правило сервиса, а не универсальное свойство HTTP.</p>\n<p>Оговорите срок хранения ключа, область уникальности и поведение при параллельных запросах. Если ключ удалили раньше повторной попытки, сервер может принять её как новую. Если запись результата и запись ключа не согласованы, окно дубля останется. Поэтому после неизвестного ответа может понадобиться reconciliation — сверка состояния с источником истины. Ключ снижает риск, но не обещает успешный исход.</p>\n<h2>Runbook для внедрения</h2>\n<ol><li>Составьте список endpoint: метод, побочный эффект, владелец состояния и способ узнать результат после разрыва.</li><li>Для каждого сигнала разделите HTTP-ответ, transport error и timeout после отправки тела. Запишите право на повтор и причину остановки.</li><li>Для записи подтвердите ключ идемпотентности, статусный endpoint или иной механизм дедупликации. При отсутствии защиты запретите автоматический retry.</li><li>Передайте один абсолютный deadline через весь вызов. Перед ожиданием сравнивайте итоговую паузу и timeout следующей попытки с остатком бюджета.</li><li>Задайте <code>base</code>, <code>cap</code> и максимум попыток. Отдельно решите, что делать с <code>Retry-After</code>, отсутствующим и некорректным заголовком.</li><li>Добавьте jitter до проверки deadline. В тестах подмените генератор и проверьте нулевую попытку, достижение cap и отрицательные параметры.</li><li>Создайте искусственные 429 и 503, timeout до ответа и timeout после отправки тела. Сверяйте число запросов, дубли записей и причину остановки.</li><li>Логируйте номер попытки, сигнал, выбранную паузу, остаток deadline и correlation id. Не записывайте токены, ключи и полное тело запроса.</li></ol>\n<h2>Ограничения и критерий готовности</h2>\n<p>Метод не описывает побочные эффекты конкретной реализации. GET может запускать плохо спроектированную команду, а POST может быть повторяемым по отдельному контракту. Проверяйте endpoint и его хранилище, а не только глагол.</p>\n<p>Retry не заменяет rate limit, circuit breaker, очередь, bulkhead или backpressure. Они ограничивают разные части системы и не дают разрешения повторить неизвестную запись. Код выше не учитывает конкретный transport, прокси, DNS, HTTP/2, балансировщик и политику upstream. В нём нет production-замеров и универсальных значений задержки.</p>\n<p>Политика готова, если для каждого endpoint команда может ответить на четыре вопроса: какой эффект повторяется, почему он безопасен, сколько времени и попыток разрешено, и что делать при неизвестном результате. Тест должен показать, что 429 ждёт <code>Retry-After</code>, 503 не синхронизирует клиентов, timeout POST не создаёт дубль без защиты, а исчерпание deadline останавливает цикл.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110: HTTP Semantics</a> — идемпотентные методы, автоматический retry после ошибки связи, <code>Retry-After</code> и 503.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc6585.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 6585: Additional HTTP Status Codes</a> — смысл 429 и возможность передать <code>Retry-After</code>.</li><li><a href=\"https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/\" target=\"_blank\" rel=\"noopener noreferrer\">AWS Builders’ Library: Timeouts, retries, and backoff with jitter</a> — практическая модель повторов и распределения нагрузки.</li><li><a href=\"https://docs.stripe.com/api/idempotent_requests\" target=\"_blank\" rel=\"noopener noreferrer\">Stripe API: Idempotent requests</a> — пример контракта ключа, сохранения результата и проверки параметров.</li></ul>"}
|