Files
progcode/editorial/agent-rewrites/005.json
T

2 lines
18 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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>"}