Files

8 lines
20 KiB
JSON
Raw Permalink 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": 273,
"slug": "editorial-2020-06-practice-retry-idempotency",
"title": "Повтор HTTP-запроса без второго эффекта: ключ, результат и бюджет",
"excerpt": "Timeout не доказывает, что сервер ничего не сделал. Разбираем, как связать один пользовательский intent с idempotency key, ограниченным retry и повторяемым результатом.",
"contentHtml": "<p>Пользователь нажимает «Отправить». Браузер ждёт ответ, затем показывает timeout. Пользователь нажимает ещё раз. Первая команда могла уже создать заказ, заявку или письмо. Ответ потерялся между сервисом и браузером. Теперь система получила два запроса и не знает, считать ли их одной операцией. Цена ошибки — двойной эффект, неверный статус на экране и ручное выяснение, что именно произошло.</p>\n<p>Отключённая кнопка не закрывает проблему. Запрос повторит браузер после обновления страницы, клиентская библиотека после разрыва соединения или прокси после сетевого сбоя. Без контракта повтор создаёт новую команду. С контрактом он возвращает результат уже начатой команды.</p>\n<h2>Тезис: повторяют intent, а не сетевой пакет</h2>\n<p>Безопасный retry начинается до первого HTTP-вызова. Клиент выделяет один пользовательский intent: конкретную операцию, набор значимых полей, пользователя и срок действия. Для intent он создаёт idempotency key — непрозрачный идентификатор попыток одной команды. Все попытки передают тот же ключ и тот же payload. Новый набор полей получает новый intent и новый ключ.</p>\n<p>Обычный <code>POST</code> не становится идемпотентным от одного заголовка. Сервер должен сам реализовать контракт: проверить scope ключа и fingerprint запроса, атомарно занять операцию, выполнить эффект один раз и сохранить terminal-ответ. Повтор с тем же ключом и тем же payload получает сохранённый ответ. Повтор с другим payload должен быть отклонён. Параллельный запрос не должен запускать второй обработчик.</p>\n<h2>Механизм по шагам</h2>\n<p>Timeout сообщает только об отсутствии ответа у клиента к дедлайну. Он не сообщает, дошёл ли запрос до сервера, завершилась ли запись и был ли отправлен ответ. Поэтому после timeout нельзя создавать новый ключ и нельзя автоматически считать операцию отменённой. Клиент либо повторяет тот же intent в пределах общего бюджета, либо показывает неопределённый исход и предлагает получить статус отдельным read-запросом.</p>\n<p>На сервере нужен scope. Для учебной заявки его можно составить из аутентифицированного субъекта, имени операции и ключа. Это не даёт одинаковой строке ключа связывать действия разных пользователей или разных операций. Fingerprint строят по значимым полям, участвующим в результате. Он защищает от ошибки, когда клиент повторно использовал старый ключ для уже изменённой формы.</p>\n<figure><img src=\"/assets/editorial/2020/retry-idempotency-request-path-2020.svg\" alt=\"Путь одного intent: ключ, первая попытка, потерянный ответ и повтор с сохранённым результатом\"><figcaption>Timeout означает, что ответ не наблюдался. Он не означает, что эффект не произошёл. Повтор с тем же ключом должен вернуть прежний результат.</figcaption></figure>\n<p>У записи операции есть как минимум четыре логических состояния: ключ не найден, операция выполняется, операция завершена с сохранённым ответом и ключ отклонён из-за другого payload. Одного поля <code>seen=true</code> недостаточно. По нему нельзя понять, ждать ли первый запрос, повторить ли готовый ответ или показать причину отказа.</p>\n<p>Проверка ключа и запись состояния должны быть атомарными. Иначе два процесса одновременно увидят «ключ не найден», оба начнут побочный эффект, а затем запишут два результата. Уникальное ограничение помогает занять одну запись, но не делает внешнюю отправку письма или вызов платёжного провайдера частью той же транзакции.</p>\n<h2>Пример клиента с общим deadline</h2>\n<p>Ниже приведён учебный пример для браузера. Домен и payload вымышлены. Генератор использует доступный в браузере <code>crypto.getRandomValues</code>, а таймаут сделан через <code>AbortController</code>, чтобы не привязывать заметку к более позднему удобному методу <code>AbortSignal.timeout</code>. В рабочем API срок хранения ключа и правила повторов задаёт серверный контракт.</p>\n<pre><code>function createKey() {\n const bytes = new Uint8Array(16);\n crypto.getRandomValues(bytes);\n return Array.from(bytes, (byte) =&gt; byte.toString(16).padStart(2, '0')).join('');\n}\n\nfunction wait(ms) {\n return new Promise((resolve) =&gt; setTimeout(resolve, ms));\n}\n\nfunction timeoutSignal(ms) {\n const controller = new AbortController();\n const timer = setTimeout(() =&gt; controller.abort(), ms);\n return { signal: controller.signal, cancel: () =&gt; clearTimeout(timer) };\n}\n\nfunction retryDelayMs(response) {\n const header = response.headers.get('Retry-After');\n if (!header) return 0;\n\n const seconds = Number(header);\n if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);\n\n const timestamp = Date.parse(header);\n return Number.isFinite(timestamp) ? Math.max(0, timestamp - Date.now()) : 0;\n}\n\nconst intent = {\n key: createKey(),\n payload: { reportType: 'bundle-size', branch: 'main' },\n deadline: Date.now() + 8_000,\n};\n\nasync function sendWithRetry() {\n for (const attempt of [1, 2]) {\n const left = intent.deadline - Date.now();\n if (left &lt;= 0) return { status: 'unknown', key: intent.key };\n\n const timeout = timeoutSignal(left);\n let response;\n try {\n response = await fetch('https://api.example.invalid/reports', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Idempotency-Key': intent.key,\n 'X-Client-Attempt': String(attempt),\n },\n body: JSON.stringify(intent.payload),\n signal: timeout.signal,\n });\n } catch (error) {\n timeout.cancel();\n if (attempt === 2) return { status: 'unknown', key: intent.key };\n continue;\n }\n timeout.cancel();\n\n if (response.ok) return response.json();\n if (![408, 429, 502, 503, 504].includes(response.status)) {\n return { status: 'rejected', code: response.status };\n }\n\n const delay = retryDelayMs(response);\n if (delay &gt;= intent.deadline - Date.now()) {\n return { status: 'unknown', key: intent.key };\n }\n if (delay &gt; 0) await wait(delay);\n }\n\n return { status: 'unknown', key: intent.key };\n}</code></pre>\n<p><code>X-Client-Attempt</code> помогает читать журнал, но не определяет идентичность операции. Идентичность задаёт только <code>Idempotency-Key</code>. Если включить номер попытки в ключ, второй запрос станет новой командой. Если при каждом timeout заново запускать восьмисекундный таймер, клиент сможет повторять запросы бесконечно долго и усилит нагрузку на уже нестабильную зависимость.</p>\n<p>Код намеренно возвращает <code>unknown</code> после исчерпания бюджета. Это не ошибка парсинга ответа и не доказательство отсутствия эффекта. Оператору или интерфейсу нужен status-маршрут, который ищет операцию по тому же ключу. Сетевой retry без такого маршрута только меняет вероятность дубля, но не устанавливает факт результата.</p>\n<h2>Симптом → причина → проверка → действие</h2>\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>После timeout появились две записи</td><td>Повтор получил новый ключ или сервер не дедуплицирует запросы</td><td>Сопоставить key, scope и счётчик эффектов</td><td>Создавать ключ до первого вызова и атомарно резервировать intent</td></tr><tr><td>Повтор отклонён из-за payload</td><td>Старый ключ использовали для изменённой формы</td><td>Сравнить fingerprint значимых полей</td><td>Закрыть старый intent и создать новый ключ</td></tr><tr><td>Два параллельных запроса выполняются вместе</td><td>Проверка ключа и запись <code>in_progress</code> не атомарны</td><td>Запустить два запроса с одним key и проверить хранилище</td><td>Добавить уникальное ограничение и ветку concurrent conflict</td></tr><tr><td>Клиент повторяет до бесконечности</td><td>У каждой попытки собственный timeout, нет общего deadline</td><td>Посчитать время от создания intent до последнего вызова</td><td>Задать общий бюджет и конечное число попыток</td></tr><tr><td>Повтор вернул другой результат</td><td>Сервис сохранил только факт ключа, но не terminal-ответ</td><td>Сравнить код, тело и result ID первой и второй попытки</td><td>Сохранять и воспроизводить ответ по контракту операции</td></tr><tr><td>Пользователь видит ошибку, хотя эффект создан</td><td>UI трактует отсутствие ответа как rollback</td><td>Проверить журнал и status-маршрут по ключу</td><td>Показывать неопределённый исход и безопасный путь проверки</td></tr></tbody></table>\n<h2>Порядок внедрения</h2>\n<ol><li>Выберите одну операцию с побочным эффектом. Зафиксируйте, что считается одним intent и какие поля определяют результат.</li><li>Создайте ключ до первого вызова. Проверьте двойной клик, обновление страницы и повтор после сетевого timeout.</li><li>Определите scope ключа: субъект, операция и срок хранения. Не используйте номер попытки или номер пользователя как единственный ключ.</li><li>Посчитайте fingerprint значимых полей. При несовпадении payload остановите запрос до побочного эффекта.</li><li>Атомарно резервируйте <code>in_progress</code>. Параллельный запрос должен ждать, получить документированный конфликт или прочитать статус.</li><li>Сохраните terminal-код, тело и идентификатор результата. Повтор с тем же key должен получить тот же наблюдаемый результат.</li><li>Задайте общий deadline и policy retry для временных ошибок. Учитывайте <code>Retry-After</code>, но не выходите за бюджет.</li><li>Проверьте сценарий «операция завершилась, ответ потерялся». Счётчик эффекта должен остаться равен одному, а повтор — вернуть тот же result ID.</li></ol>\n<h2>Отрицательный путь и ограничения</h2>\n<p>Не каждый отказ можно повторять. Ошибка валидации требует исправить payload. Ошибка авторизации требует обновить права или сессию. Повтор <code>4xx</code> без изменения причины создаёт шум. Коды <code>502</code>, <code>503</code> и <code>504</code> могут указывать на временную проблему, но сами по себе не доказывают, что сервер не выполнил операцию.</p>\n<p>Идемпотентность одной границы не распространяется на внешнюю систему. Если обработчик сначала создаёт запись у себя, а затем отправляет письмо в сервис без ключа, повторная доставка письма всё ещё может дать дубль. Нужны outbox, устойчивый бизнес-идентификатор или поддержка дедупликации у поставщика. Если внешний эффект необратим и его результат неизвестен, безопаснее остановиться и получить статус, чем слепо отправить новую команду.</p>\n<p>Ключ не заменяет авторизацию, шифрование и контроль доступа. Он не должен раскрывать результат другому субъекту. Запись ключей очищают по опубликованному сроку, который должен покрывать retry-budget. Чувствительные поля нельзя бездумно помещать в журналы. Учебные восемь секунд, список кодов и домен из примера нельзя переносить в рабочие лимиты без измерений конкретной системы.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Операция готова к ограниченному retry, если команда может воспроизвести четыре сценария: успешный первый ответ, timeout после выполнения, параллельный повтор и изменение payload под старым ключом. В первом сценарии клиент получает результат. Во втором система создаёт один эффект и возвращает тот же result ID. В третьем выполняется один обработчик. В четвёртом сервер отклоняет запрос до побочного эффекта.</p>\n<p>Отдельно проверьте бюджет: после его исчерпания нет новых попыток, интерфейс показывает неопределённый исход или использует предусмотренный status-маршрут. Критерий наблюдаем по журналу запросов, состояниям записи и счётчику эффекта. Он не зависит от предположения, что потерянный ответ означает отменённую операцию.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://www.rfc-editor.org/rfc/rfc7231.html#section-4.2.2\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 7231, раздел 4.2.2: идемпотентные методы и повтор после communication failure</a> — нормативное определение HTTP на момент материала. Оно не делает <code>POST</code> идемпотентным само по себе.</li><li><a href=\"https://datatracker.ietf.org/doc/html/draft-idempotency-header-01\" target=\"_blank\" rel=\"noopener noreferrer\">IETF draft-idempotency-header-01</a> — исторический work in progress от ноября 2020 года о ключе, fingerprint, concurrent request и сроке действия. Это не готовый стандарт и не универсальная гарантия поддержки заголовка.</li><li><a href=\"https://docs.stripe.com/api/idempotent_requests\" target=\"_blank\" rel=\"noopener noreferrer\">Stripe API: Idempotent requests</a> — пример конкретного API-контракта, где сохраняются status code и body результата. Правила Stripe нельзя переносить на другой сервис без его документации.</li></ul>"
}