Files
progcode/editorial/agent-rewrites/005.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 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": "Повтор запроса помогает пережить временный сбой только после проверки идемпотентности, статуса, Retry-After и общего deadline. Разбираем backoff, jitter и отрицательный путь для записей.",
"contentHtml": "<p>Симптом обычно выглядит просто: upstream отвечает 503 или 429, клиент ждёт timeout, а затем несколько раз отправляет тот же запрос. Если таких клиентов много, восстановление превращается во вторую волну нагрузки. Растут latency и очередь, здоровые запросы получают меньше ресурсов. Для POST цена выше: сервер мог принять запись до разрыва соединения, а повтор может создать второй заказ, платёж или задачу.</p>\n<p>Тезис статьи короткий: retry — это решение о семантике, а не число попыток в конфиге. Сначала нужно понять, можно ли безопасно повторить операцию и как узнать результат первой попытки. Только после этого выбирают статус, задержку, jitter и предел. Backoff не делает небезопасную запись безопасной. Idempotency key не отменяет deadline и не заменяет проверку состояния.</p>\n<h2>Механизм ошибки</h2>\n<p>У клиента есть два независимых вопроса. Первый: разрешён ли повтор. Второй: когда его отправить. Код ответа и HTTP-метод помогают ответить на первый вопрос, но не описывают всю бизнес-семантику. GET обычно читает ресурс. PUT и DELETE относятся к идемпотентным методам по смыслу HTTP: повтор не должен менять запрошенный эффект. POST часто создаёт новую операцию, поэтому таймаут оставляет результат неизвестным.</p>\n<p>Неизвестный результат важнее слова «ошибка». Клиент мог отправить тело, сервер мог записать данные, а ответ мог потеряться при обратной передаче. В этом случае повтор — не восстановление связи, а новая попытка выполнить команду. Для записи нужен один из трёх путей: idempotency key с серверной дедупликацией, запрос статуса операции или контракт, который делает повтор фактически идемпотентным. Если ни одного пути нет, автоматический retry должен закончиться отказом с сохранением диагностического контекста.</p>\n<p>Коды 429 и 503 тоже не дают универсального разрешения. 429 означает ограничение частоты; ответ может содержать Retry-After. 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 с Retry-After</td><td>Сработал лимит частоты</td><td>Прочитать заголовок и область лимита</td><td>Повторить только идемпотентную операцию, не раньше разрешённого времени</td></tr><tr><td>503 без Retry-After</td><td>Upstream временно недоступен или перегружен</td><td>Сверить статус, deadline и счётчик попыток</td><td>Применить ограниченный backoff с jitter</td></tr><tr><td>Timeout GET</td><td>Ответ потерян или сервер ещё работает</td><td>Повторно прочитать ресурс и проверить остаток deadline</td><td>Повторить чтение при наличии бюджета</td></tr><tr><td>Timeout POST</td><td>Запись могла завершиться</td><td>Проверить idempotency key или статус операции</td><td>Не отправлять второй POST без защиты</td></tr><tr><td>400, 401 или 403</td><td>Неверные данные или права</td><td>Посмотреть тело ответа и авторизацию</td><td>Не повторять автоматически</td></tr></tbody></table>\n<h2>Backoff и jitter</h2>\n<p>Экспоненциальный backoff снижает частоту повторов по мере роста номера попытки. Базовая формула: <code>min(cap, base * 2^attempt)</code>. Параметр <code>cap</code> не даёт задержке расти бесконечно. Но один backoff не решает проблему синхронизации. Если тысячи клиентов получили сбой в одном интервале, одинаковая формула разбудит их почти одновременно.</p>\n<p>Jitter добавляет случайное смещение. В простом варианте клиент выбирает задержку в диапазоне от нуля до рассчитанного значения. В другом варианте он добавляет небольшой случайный интервал к deterministic backoff. Выбор варианта зависит от клиента и нагрузки. Важно не смешать случайность с бесконтрольным ожиданием: итоговая задержка всё равно должна укладываться в общий deadline и максимальное число попыток.</p>\n<p>Deadline принадлежит всей операции. Нельзя выдавать каждой попытке новый полный timeout. Если у операции осталось 120 миллисекунд, а рассчитанный backoff равен 500 миллисекундам, нужно завершить операцию или перейти к проверке состояния. Иначе локальный retry будет скрывать задержку от вызывающего кода и увеличивать очередь. В журналах сохраняйте номер попытки, статус, задержку, остаток deadline и причину остановки. Не записывайте секреты и полное тело запроса.</p>\n<figure><img src=\"/assets/editorial/2027/mistakes-revisions-2027-condition-correction-matrix.svg\" alt=\"Матрица решения о retry: метод и статус определяют право на повтор, затем применяются deadline, backoff и jitter; timeout записи требует идемпотентного ключа.\" loading=\"lazy\" /><figcaption>Сначала проверяется семантика операции. Только после этого выбирается задержка. Нижняя граница схемы напоминает: deadline и идемпотентность важнее числа попыток.</figcaption></figure>\n<h2>Учебный пример политики</h2>\n<p>Ниже — учебная функция для расчёта задержки. Она не выполняет HTTP-запрос, не генерирует случайность и не знает, разрешён ли retry для конкретного метода. Jitter передаётся числом, чтобы пример имел воспроизводимый результат. В реальном клиенте случайное значение нужно получать через управляемый генератор и сравнивать итог с остатком deadline.</p>\n<pre><code>function retryDelay(attempt, baseMs, capMs, jitterMs) {\n if (!Number.isInteger(attempt) || attempt &lt; 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 &lt; 0 || capMs &lt; baseMs || jitterMs &lt; 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 }</code></pre>\n<p>Числа в примере учебные. На попытке с индексом 3 экспоненциальная часть равна 800 миллисекундам, потому что <code>100 * 2^3</code> не превышает cap 1000. Этот расчёт не доказывает, что задержка подходит вашему upstream. Для подбора параметров нужны его лимиты, общий deadline, размер пула и допустимая нагрузка. Если jitter добавить после проверки deadline, клиент всё равно может превысить бюджет, поэтому проверку выполняют для итоговой задержки.</p>\n<h2>Idempotency key и отрицательный путь</h2>\n<p>Request ID связывает попытки в логах. Он не заставляет сервер считать их одной операцией. Idempotency key должен входить в контракт endpoint. Сервер хранит ключ вместе с идентификатором операции и результатом в течение согласованного времени. При повторе с тем же ключом и тем же содержимым он возвращает тот же результат или текущее состояние. При другом содержимом сервер должен отклонить запрос, а не молча перезаписать первую операцию.</p>\n<p>У ключа есть ограничения. Он может истечь до того, как клиент повторит запрос. Сбой может произойти между записью результата и сохранением записи о ключе. Разные пользователи могут случайно выбрать одинаковый ключ, если сервер не включает владельца в область уникальности. При смене версии схемы может потребоваться отдельная совместимость. Поэтому ключ снижает риск дубля, но не обещает успешный исход и не заменяет reconciliation.</p>\n<p>Отрицательный путь должен быть явным. Если POST завершился timeout, нет ключа и нет API статуса, клиент не повторяет его автоматически. Он возвращает состояние «результат неизвестен», сохраняет correlation id и предлагает безопасную проверку. Если upstream отвечает 400, клиент исправляет запрос или показывает ошибку. Если deadline закончился во время backoff, клиент останавливается. Повтор ради заполнения метрики успешных ответов не является корректным действием.</p>\n<h2>Порядок настройки retry</h2>\n<ol><li>Опишите endpoint, HTTP-метод, побочные эффекты и источник истины. Для записи укажите, как узнать результат после разрыва соединения.</li><li>Разделите статусы и сетевые сбои. Для каждого сигнала задайте право на повтор, причину остановки и ожидаемый ответ сервера.</li><li>Проверьте idempotency key, статусную операцию или иной механизм дедупликации. Если механизм отсутствует, запретите автоматический retry для неизвестного результата.</li><li>Передайте один абсолютный deadline через весь вызов. Перед каждой попыткой сравнивайте остаток времени с рассчитанной задержкой и timeout самой попытки.</li><li>Задайте base, cap и максимум попыток. Проверьте нулевую попытку, достижение cap, отрицательные и нечисловые параметры.</li><li>Добавьте jitter после расчёта backoff, но до проверки deadline. В тесте подмените генератор случайных чисел или передайте фиксированное значение.</li><li>Обработайте Retry-After. Не отправляйте запрос раньше указанного сервером времени, если это не запрещает deadline. Некорректный заголовок обработайте как недоверенное значение.</li><li>Проверьте искусственные 429, 503, timeout до ответа и timeout после отправки тела. Сверяйте число запросов, дубли записей и время до остановки.</li></ol>\n<h2>Ограничения</h2>\n<p>HTTP-метод не описывает побочные эффекты конкретного сервиса. GET может запускать плохо спроектированную команду, а POST может быть идемпотентным по отдельному контракту. Проверяйте реализацию endpoint, а не только название метода.</p>\n<p>Retry не заменяет circuit breaker, rate limit, очередь, bulkhead и backpressure. Эти механизмы решают разные задачи. Circuit breaker ограничивает вызовы при устойчивом сбое. Rate limit распределяет бюджет запросов. Очередь меняет момент выполнения. Ни один из них не разрешает повторить неизвестную запись без проверки семантики.</p>\n<p>Таблица и код выше не являются готовой библиотекой. Они не учитывают конкретный transport, прокси, DNS, HTTP/2, балансировщик и политику upstream. Пример не содержит production-замеров и не заявляет универсальные значения base, cap или jitter. Эти параметры нужно подтвердить тестом на вашей границе нагрузки.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Политика готова, если для каждого endpoint можно ответить на четыре вопроса: какую операцию повторяем, почему она безопасна, сколько времени и попыток разрешено, и что делаем при неизвестном результате. Тест должен показать, что 429 учитывает Retry-After, 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> — определения safe и idempotent methods, а также правила повторной отправки после ошибки связи.</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 Too Many Requests и заголовка Retry-After.</li></ul>"
}