8 lines
20 KiB
JSON
8 lines
20 KiB
JSON
{
|
||
"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) => byte.toString(16).padStart(2, '0')).join('');\n}\n\nfunction wait(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction timeoutSignal(ms) {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), ms);\n return { signal: controller.signal, cancel: () => 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 <= 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 >= intent.deadline - Date.now()) {\n return { status: 'unknown', key: intent.key };\n }\n if (delay > 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>"
|
||
}
|