{ "index": 273, "slug": "editorial-2020-06-practice-retry-idempotency", "title": "Повтор HTTP-запроса без второго эффекта: ключ, результат и бюджет", "excerpt": "Timeout не доказывает, что сервер ничего не сделал. Разбираем, как связать один пользовательский intent с idempotency key, ограниченным retry и повторяемым результатом.", "contentHtml": "
Пользователь нажимает «Отправить». Браузер ждёт ответ, затем показывает timeout. Пользователь нажимает ещё раз. Первая команда могла уже создать заказ, заявку или письмо. Ответ потерялся между сервисом и браузером. Теперь система получила два запроса и не знает, считать ли их одной операцией. Цена ошибки — двойной эффект, неверный статус на экране и ручное выяснение, что именно произошло.
\nОтключённая кнопка не закрывает проблему. Запрос повторит браузер после обновления страницы, клиентская библиотека после разрыва соединения или прокси после сетевого сбоя. Без контракта повтор создаёт новую команду. С контрактом он возвращает результат уже начатой команды.
\nБезопасный retry начинается до первого HTTP-вызова. Клиент выделяет один пользовательский intent: конкретную операцию, набор значимых полей, пользователя и срок действия. Для intent он создаёт idempotency key. Все попытки этой операции передают тот же ключ и тот же payload. Новый набор полей получает новый intent и новый ключ.
\nКлюч не делает POST идемпотентным сам по себе. Сервер должен принять решение по паре «scope ключа и fingerprint запроса», атомарно занять операцию, выполнить эффект один раз и сохранить terminal-ответ. Повтор с тем же ключом получает сохранённый ответ. Повтор с тем же ключом, но другим payload получает конфликт. Параллельный запрос видит состояние in_progress, а не запускает второй обработчик.
Timeout сообщает только об отсутствии ответа у клиента к дедлайну. Он не сообщает, дошёл ли запрос до сервера, завершилась ли запись и был ли отправлен ответ. Поэтому после timeout нельзя создавать новый ключ и нельзя автоматически считать операцию отменённой. Клиент либо повторяет тот же intent в пределах общего бюджета, либо показывает неопределённый исход и предлагает получить статус отдельным read-запросом.
\nНа сервере нужен scope. Для учебной заявки его можно составить из аутентифицированного субъекта, имени операции и ключа. Это не даёт одинаковой строке ключа связывать действия разных пользователей или разных операций. Fingerprint строят по значимым полям. Его сравнение защищает от ошибки, когда клиент повторно использовал старый ключ для уже изменённой формы.
\nУ записи операции есть как минимум четыре состояния: ключ не найден, операция выполняется, операция завершена с сохранённым ответом и ключ отклонён из-за другого payload. Одного поля seen=true недостаточно. По нему нельзя понять, ждать ли первый запрос, повторить ли готовый ответ или показать причину отказа.
Ниже приведён учебный пример. Домен api.example.invalid, идентификатор заявки и тайминги вымышлены. Код показывает границу ответственности: ключ создаётся один раз, deadline относится ко всему intent, а не к каждой попытке.
const intent = {\n key: crypto.randomUUID(),\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 try {\n const 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: AbortSignal.timeout(left),\n });\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 } catch (error) {\n if (attempt === 2) return { status: 'unknown', key: intent.key };\n }\n }\n\n return { status: 'unknown', key: intent.key };\n}\nX-Client-Attempt помогает читать журнал, но не определяет идентичность операции. Если включить номер попытки в idempotency key, второй запрос станет новой командой. Если при каждом timeout заново запускать восьмисекундный таймер, клиент сможет повторять запросы бесконечно долго и усилит нагрузку на уже нестабильную зависимость.
Сервис должен хранить ключ вместе со scope, fingerprint, состоянием, HTTP-кодом и телом terminal-ответа. При первой попытке он резервирует запись до выполнения побочного эффекта. После успеха или окончательной ошибки он заполняет результат. При повторе с тем же fingerprint сервис отдаёт эту запись. Учебный код не утверждает, что конкретная база или транспорт уже дают такую атомарность: её нужно обеспечить отдельно.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| После timeout появились две записи | Повтор получил новый ключ или сервер не дедуплицирует запросы | Сопоставить ключ, scope и счётчик побочных эффектов | Создавать ключ до первого вызова и атомарно резервировать intent |
| Повтор получает конфликт из-за payload | Старый ключ использовали для изменённой формы | Сравнить fingerprint значимых полей | Закрыть старый intent и создать новый ключ после изменения данных |
| Два параллельных запроса выполняются одновременно | Проверка ключа и запись in_progress не атомарны | Запустить два запроса с одним key и проверить порядок в хранилище | Добавить уникальное ограничение и отдельную ветку для уже занятой операции |
| Клиент повторяет запросы до бесконечности | У каждой попытки собственный timeout, нет общего deadline | Посчитать время от создания intent до последнего вызова | Задать общий бюджет и конечное число попыток |
| Повтор вернул пустой или другой результат | Сервис сохранил только факт ключа, но не terminal-ответ | Сравнить код, тело и идентификатор результата первой и второй попытки | Сохранять и воспроизводить ответ по контракту операции |
| Пользователь видит ошибку, хотя эффект создан | UI трактует отсутствие ответа как rollback | Проверить серверный журнал и status-маршрут по ключу | Показывать неопределённый исход и дать безопасный путь проверки |
in_progress. Параллельный запрос должен ждать, получить документированный конфликт или прочитать статус.Не каждый отказ можно повторять. Ошибка валидации требует исправить payload. Ошибка авторизации требует обновить права или сессию. Повтор 4xx без изменения причины только создаёт шум. Коды 502, 503 и 504 могут указывать на временную проблему, но сами по себе не доказывают, что сервер не выполнил операцию.
Идемпотентность одной границы не распространяется на внешнюю систему. Если обработчик сначала создаёт запись у себя, а затем отправляет письмо в сервис без ключа, повторная доставка письма всё ещё может дать дубль. Нужны outbox, устойчивый бизнес-идентификатор или поддержка дедупликации у поставщика. Если внешний эффект необратим и его результат неизвестен, безопаснее остановиться и получить статус, чем слепо отправить новую команду.
\nКлюч не заменяет авторизацию, шифрование и контроль доступа. Он не должен раскрывать результат другому субъекту. Запись ключей нужно очищать по документированному сроку, а чувствительные поля нельзя бездумно помещать в журналы. Учебные значения из примера нельзя переносить в рабочие лимиты, схемы хранения или правила повторов без измерений конкретной системы.
\nОперация готова к ограниченному retry, если команда может воспроизвести четыре сценария: успешный первый ответ, timeout после выполнения, параллельный повтор и изменение payload под старым ключом. В первом сценарии клиент получает результат. Во втором система создаёт один эффект и возвращает тот же result ID. В третьем выполняется один обработчик. В четвёртом сервер отклоняет запрос до побочного эффекта.
\nОтдельно проверьте бюджет: после его исчерпания нет новых попыток, UI показывает неопределённый исход или использует предусмотренный status-маршрут. Такой критерий наблюдаем по журналу запросов, состояниям записи и счётчику эффекта. Он не зависит от предположения, что потерянный ответ означает отменённую операцию.
\n