{ "index": 17, "slug": "editorial-2027-07-mechanism-reliability-capstone", "title": "Retry после timeout: как не повторить бизнес-операцию дважды", "excerpt": "Timeout сообщает о потерянном ответе, но не о результате записи. Идемпотентный контракт связывает повторы одной операции и не даёт сетевому сбою превратиться в дубль.", "contentHtml": "
Клиент отправляет запрос на создание заказа и ждёт ответа. Через пять секунд он получает timeout. Пользователь нажимает «Повторить», а клиент автоматически отправляет тот же POST ещё раз. В системе появляются два заказа. Для платежа цена выше: можно получить двойное списание, повторное письмо или две отгрузки.
\nTimeout говорит только о том, что клиент не получил ответ в отведённый срок. Сервер мог не принять запрос, мог отклонить его или уже записать результат, пока ответ терялся между сервисами. Поэтому retry нельзя строить вокруг одной ошибки сети. Нужно знать, какой эффект имеет операция и как сервер распознаёт повтор.
\nТезис: надёжный retry начинается с контракта бизнес-операции. Для чтения достаточно ограниченного повтора с общим deadline. Для записи нужен идемпотентный метод или ключ операции, который сервер проверяет атомарно вместе с результатом. Один request id, повторная отправка POST и надежда на быстрый ответ такой контракт не заменяют.
\nТранспорт доставляет байты и сообщает о состоянии соединения. Он не знает, зафиксирована ли транзакция в базе. HTTP описывает метод, статус и заголовки, но не видит внутреннюю границу между записью и отправкой ответа. Если процесс записал заказ, а затем упал до ответа, клиент получает неопределённый исход: операция могла завершиться.
\nДля GET повтор обычно не создаёт новый объект. Для DELETE повтор может вернуть другой статус, но ожидаемое состояние остаётся удалённым. POST по умолчанию не даёт такой гарантии: сервер может создать новый ресурс при каждом запросе. Нельзя выводить безопасность повтора из короткого имени метода. Нужно проверить доменное действие.
\n| Идентификатор | Кто создаёт | Что связывает | Чего не гарантирует |
|---|---|---|---|
| Connection ID | транспорт | пакеты одного соединения | результат бизнес-операции |
| Request ID | клиент или gateway | одну попытку и её логи | отсутствие повторного эффекта |
| Idempotency-Key | клиент для операции | несколько попыток одного действия | атомарность, если сервер её не реализует |
| Resource ID | доменный сервис | созданный объект | связь двух попыток без контракта |
Операция идемпотентна, если один или несколько одинаковых запросов дают тот же ожидаемый эффект, что и один запрос. Ответы при этом могут отличаться. Первый вызов может вернуть 201, повтор — сохранённый 200 или 409 по правилам API. Проверять нужно состояние и контракт, а не только код ответа.
\nДля создания ресурса сервер может принять Idempotency-Key. Он сохраняет связь между ключом, параметрами операции и результатом. Повтор с тем же ключом возвращает сохранённый результат или определённую ошибку. Повтор с тем же ключом, но другим телом должен завершаться конфликтом. Иначе старый результат можно ошибочно выдать за результат новой команды.
\nКлюч относится к смысловой операции, а не к соединению. Его область уникальности и срок хранения должны быть явными. Ключ order-42 может быть уникальным в пределах одного клиента, магазина или всей системы. После истечения срока тот же ключ может стать новой операцией. Это часть API-контракта.
Ниже — ограниченный учебный пример на Node.js. Он показывает только идею: два POST с одним ключом получают один номер записи. Данные хранятся в Map в памяти процесса. Пример не заменяет транзакцию, распределённое хранилище, аутентификацию и проверку тела запроса.
\nconst results = new Map(); let nextId = 1; function create(key) { if (!results.has(key)) results.set(key, { id: nextId++, state: 'created' }); return results.get(key); } console.log(create('order-42')); console.log(create('order-42'));\nВ учебном запуске оба ответа содержат один id. Это ожидаемое свойство примера, а не результат работы реального сервиса. В настоящей системе проверка ключа и создание записи должны проходить под атомарным ограничением. Два процесса не должны одновременно увидеть отсутствующий ключ и создать два ресурса.
Хранилище должно запоминать параметры операции или их отпечаток. Если первый запрос создаёт заказ на 100 рублей, а повтор с тем же ключом просит 10 000 рублей, сервер не должен молча отдавать старый результат. Он должен вернуть конфликт до нового побочного эффекта. Успешный результат, ошибка валидации и ошибка сервера требуют отдельных правил хранения.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Timeout, но ресурс уже есть | Ответ потерялся после записи | Сопоставить trace, request id и состояние ресурса | Повторить только с тем же ключом или запросить результат по resource id |
| Каждый retry создаёт новый ресурс | POST не имеет дедупликации | Отправить два запроса с одним ключом и сравнить записи | Добавить контракт ключа или запретить автоматический retry |
| Один ключ даёт разные ответы | Ключ не связан с результатом или истёк | Проверить TTL и область уникальности | Зафиксировать срок, namespace и правило истечения |
| Повтор с другим телом проходит | Сервер хранит только строку ключа | Сравнить отпечатки тел | Вернуть конфликт до побочного эффекта |
| После сбоя растёт очередь | Retry не учитывает deadline | Посчитать попытки, задержки и время отмены | Ограничить повторы, backoff и бюджет времени |
Не повторяйте запись, если сервер не обещает идемпотентность и нельзя отдельно проверить состояние. Это отрицательный путь. Лучше вернуть неопределённый результат и передать операцию на доменную проверку, чем незаметно создать второй эффект.
\nНе превращайте любой 5xx в разрешение на повтор. 503 может сопровождаться Retry-After, но его наличие не доказывает, что запрос не был принят. Gateway может вернуть свой 503 после выполнения upstream-операции. Сетевое исключение, отмена deadline и ответ посредника должны различаться в логах.
Не повторяйте после истечения общего deadline. Отдельные таймауты на каждый вызов могут растянуть цепочку на минуты и создать лавину в зависимостях. Backoff снижает частоту, но не исправляет небезопасный эффект. Circuit breaker ограничивает давление, но не сообщает, была ли запись принята.
\nIdempotency-Key не решает конкуренцию сам по себе. Нужны атомарная запись, согласованное хранилище и правило восстановления после сбоя между фиксацией результата и сохранением ответа. В многорегиональной системе область уникальности должна охватывать все узлы, которые принимают операцию.
\nСрок хранения ключей выбирают по максимальному времени повторной доставки и бизнес-риску. Слишком короткий TTL снова разрешит дубль. Слишком длинный TTL увеличит хранилище. Платёжные и юридически значимые операции требуют отдельного доменного контракта, аудита и проверки состояния.
\nУчебный сервер не моделирует рестарт, несколько процессов, транзакцию базы, частичный ответ и истечение TTL. Он полезен только для проверки различия между попыткой и операцией. Не переносите его Map в production без этих механизмов.
\nМеханизм готов, если для одного operation key можно воспроизвести четыре исхода: успешный первый запрос, timeout после записи, повтор с тем же телом и повтор с другим телом. В первых двух случаях состояние содержит один ресурс и один побочный эффект. Третий случай возвращает тот же результат или согласованный статус. Четвёртый возвращает конфликт до новой записи. Все попытки видны по operation key, request id и номеру попытки, а общий deadline ограничивает цепочку.
\nЕсли хотя бы один исход нельзя проверить тестом или наблюдаемым сигналом, retry остаётся предположением. В таком месте автоматический повтор нужно отключить до появления контракта.
\n