{ "index": 271, "slug": "editorial-2020-06-field-retry-idempotency", "title": "Потерянный ответ и повтор запроса: как не создать второй эффект", "excerpt": "Timeout сообщает только о потерянном ответе. Разбираем связь requestId и Idempotency-Key, replay сохранённого результата, конфликт payload и границу, за которой автоматический retry нужно остановить.", "contentHtml": "
Пользователь отправляет заявку, ждёт две секунды и видит timeout. Он нажимает кнопку ещё раз. Через минуту в системе появляются две заявки, два письма или два списания. В логах первой попытки уже есть 201 Created, но браузер его не получил. Цена ошибки — не только дубль. Команда тратит время на ручное удаление, пользователь не понимает, какая запись настоящая, а повторная попытка может уйти во внешнюю систему, где откат невозможен.
Timeout не доказывает, что сервер ничего не сделал. Он говорит только, что конкретный клиент не дождался события в своём лимите. Запрос мог не выйти из клиента, мог застрять в proxy, мог завершить запись после закрытия соединения или мог сохранить результат и потерять ответ на обратном пути.
\nТезис простой: безопасный retry повторяет один пользовательский intent, а не последний HTTP-пакет. Для этого сервер связывает попытки по одному Idempotency-Key, проверяет тот же значимый payload и сохраняет terminal-результат. requestId при этом меняется на каждой сетевой попытке. Если сервис не умеет отличить повтор от новой команды, автоматический retry после неизвестного исхода нужно остановить.
requestId отвечает на вопрос «какой сетевой проход мы сейчас видим?». Его создают для запроса, который прошёл через клиент, proxy и приложение. При повторе он должен быть новым. Это помогает собрать логи именно этой доставки.
Idempotency-Key отвечает на другой вопрос: «какие доставки относятся к одному действию пользователя?». Клиент сохраняет его рядом с payload с момента подтверждения формы до terminal-результата. Повтор после timeout отправляет тот же ключ и те же значимые поля. Если пользователь изменил заявку, появился новый intent. Старый ключ нельзя переиспользовать для нового тела.
Заголовок не делает POST идемпотентным сам по себе. Контракт должен описать область уникальности ключа, способ сравнения тела, срок хранения записи и ответ для повтора. В одной области ключ может быть уникален для пользователя, клиента, заказа или другого владельца операции. Нельзя выбрать область по удобству таблицы: слишком широкая область блокирует чужие операции, слишком узкая пропускает дубль.
requestId показывает новую доставку. Тот же Idempotency-Key связывает её с прежним intent.Серверу нужна запись состояния операции, а не флаг seen=true. Минимальная модель содержит область ключа, сам ключ, отпечаток значимого payload, состояние, HTTP-статус, безопасное тело ответа, идентификатор результата и срок хранения.
Первый запрос атомарно резервирует ключ в состоянии in_progress. Только владелец резерва может выполнить бизнес-эффект. Параллельный запрос с тем же ключом не запускает обработчик второй раз: он получает документированный ответ «операция выполняется» или читает результат после завершения. Запрос с другим отпечатком получает конфликт до бизнес-эффекта.
После эффекта сервис сохраняет completed и данные, которые нужны для повторного ответа. Для операции внутри одной базы запись ключа, бизнес-объект и terminal-результат стоит зафиксировать одной транзакцией. Тогда повтор видит либо согласованный результат, либо отсутствие всей операции. Само наличие уникального индекса не заменяет обработку состояний, но защищает от гонки двух процессов.
POST /v1/demo-requests HTTP/1.1\nIdempotency-Key: demo-key-271-a7f1\nX-Request-Id: req-51\nContent-Type: application/json\n\n{\"topic\":\"bundle review\",\"note\":\"учебная заявка\"}\n\n// Ответ потерян для клиента. Повтор:\nPOST /v1/demo-requests HTTP/1.1\nIdempotency-Key: demo-key-271-a7f1\nX-Request-Id: req-52\nContent-Type: application/json\n\n{\"topic\":\"bundle review\",\"note\":\"учебная заявка\"}\n\n// Ожидаемый контракт учебного примера:\nHTTP/1.1 201 Created\n{\"requestId\":\"demo-request-42\",\"state\":\"accepted\"}\nЭто учебный пример. Адрес, ключ, идентификаторы и тело не относятся к production-сервису. Он проверяет только логическое свойство: один ключ и один payload дают один результат, а повтор возвращает тот же результат. Если первый запрос завершился эффектом, но ответ не дошёл до клиента, второй вызов не создаёт новую запись.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| После timeout появились две записи | Повтор получил новый ключ или резерв не был атомарным | Сверить ключи, отпечатки payload и terminal-записи | Остановить blind retry; добавить уникальную границу и тест гонки |
В логах есть 201, UI показал ошибку | Результат сохранился после отправки или до потери ответа | Сопоставить время записи, response и client timeout | Повторить тем же ключом и вернуть сохранённый result ID |
| Тот же ключ пришёл с другим телом | Ключ переиспользовали для нового intent или клиент изменил payload | Сравнить canonical payload или его fingerprint | Вернуть отдельный conflict без запуска обработчика |
Повтор видит in_progress | Первая попытка ещё работает или оборвалась до terminal-записи | Проверить владельца резерва, heartbeat и дедлайн | Вернуть состояние по контракту; не удалять запись ради нового запуска |
| Внутри базы дубля нет, снаружи он есть | Внешний вызов был до сохранения terminal-состояния | Найти стабильный business ID и запись поставщика | Остановить retry до внешнего ключа или маршрута сверки результата |
Для первой проверки не нужна полноценная distributed tracing система. Достаточно безопасных полей в существующих журналах: время, маршрут, requestId, нормализованный идентификатор ключа, решение дедупликации, состояние и result ID. Полный payload, cookie, authorization и секрет самого ключа в общий лог не кладут. Они расширяют риск утечки и редко помогают отличить replay от второго эффекта.
У операции должен быть общий дедлайн intent. В него входят ожидание первой попытки, пауза, допустимый retry и время, за которое интерфейс покажет неопределённый исход. Каждый новый retry не должен начинать этот бюджет заново. Иначе перегруженная зависимость получает бесконечный поток одинаковых команд.
\nНужно отдельно проверить отношения таймеров. Клиент может закрыть ожидание раньше proxy. Proxy может продолжить запрос после закрытия вкладки. Сервер может записать результат после client timeout. Это допустимые варианты доставки. Ошибка появляется там, где следующий запрос не несёт связь с первым intent и превращается в новую команду.
\nСтатус сам по себе не выбирает действие. 503 может сопровождаться Retry-After, но он не расширяет общий deadline. 4xx обычно останавливает автоматический повтор, однако плохой контракт сервера может сохранить эффект до формирования ответа. Поэтому ключ, состояние операции и журнал важнее простого списка кодов.
Если сервис не хранит idempotency-запись, клиент не знает, был ли эффект. В этом случае нельзя честно сказать «повтор безопасен». Покажите неопределённый исход, сохраните данные формы и передайте операцию в путь проверки статуса. Для платежа, заказа, письма или другой необратимой команды это безопаснее второго POST вслепую.
Внешняя система создаёт отдельную границу. Локальная транзакция не откатывает HTTP-вызов поставщику. Сервис может вызвать поставщика, получить результат, упасть до записи completed и после рестарта увидеть только in_progress. Удалить такую строку и повторить вызов — способ создать дубль. Нужен стабильный внешний идентификатор, idempotency-механизм поставщика или проверка результата по business ID.
Срок хранения ключа тоже часть контракта. Он должен покрывать максимальный retry-budget, задержки промежуточных звеньев и разрешённую задержку повторной отправки. Очищать можно terminal-записи после окончания окна. Удалять зависший in_progress без процедуры восстановления нельзя: очистка скрывает неизвестный эффект, а не устраняет его.
requestId и общий Idempotency-Key. Отсутствующее поле отметить как пробел доказательств.in_progress, completed и conflict. Для каждого состояния заранее записать машинный ответ и действие клиента.in_progress. Проверить, что уборка не запускает повторную команду.Контракт готов, если независимый инженер может по одному intent ответить на пять вопросов: какой ключ связывает попытки, какой payload считается тем же, кто владеет эффектом, какой ответ получает replay и что происходит при неизвестном исходе. Тест с потерянным ответом показывает ровно один эффект и тот же result ID на повторе. Тест с другим payload получает conflict до эффекта. Два конкурентных запроса не создают две terminal-записи.
\nДля внешнего эффекта дополнительно существует проверяемый путь сверки после рестарта или обрыва между вызовом и записью. Если такого пути нет, готовность не подтверждена, даже если локальный unit test зелёный. Это граница механизма: идемпотентность уменьшает повтор одного intent, но не даёт гарантии exactly once между независимыми системами.
\n