{ "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 и короткий timeout такой контракт не заменяют.
\nТранспорт управляет соединением и передачей данных, но не знает, зафиксирована ли транзакция в базе. HTTP описывает метод, статус и заголовки, однако не видит внутреннюю границу между записью и отправкой ответа. Если процесс сохранил заказ и упал перед ответом, клиент получает неопределённый исход: операция могла завершиться.
\nRFC 9110 называет идемпотентным такой метод, у которого несколько одинаковых запросов имеют тот же предполагаемый эффект, что и один. В стандарте к ним относятся безопасные методы, PUT и DELETE. RFC отдельно предупреждает: клиенту не следует автоматически повторять неидемпотентный метод, если он не знает прикладную семантику или не умеет проверить, что исходный запрос не применился.
\nЭто не означает, что любой GET можно повторять бесконечно. Чтение не добавляет запись, но каждый вызов потребляет квоту и нагрузку. Повтор всё равно ограничивают числом попыток и общим временем. И наоборот, POST не обречён на дубль: конкретный API может сделать его идемпотентным через ключ, предварительное условие или другую часть контракта.
\n| Идентификатор | Кто создаёт | Что связывает | Чего не доказывает |
|---|---|---|---|
| Connection ID | транспорт | пакеты одного соединения | результат бизнес-операции |
| Request ID | клиент или gateway | одну попытку и её логи | отсутствие повторного эффекта |
| Idempotency-Key | клиент для операции | несколько попыток одного действия | атомарность без серверной реализации |
| Resource ID | доменный сервис | созданный или изменённый объект | что именно сделал этот клиент |
Идемпотентность описывает итоговое изменение состояния, а не одинаковость ответов и не отсутствие логов. Первый вызов может вернуть 201, повтор — сохранённый 200 или 409 по правилам API. Проверять нужно число созданных ресурсов, состояние и побочные эффекты, а не только код ответа.
\nДля POST сервер часто принимает заголовок Idempotency-Key. Это не универсальная гарантия HTTP и не стандартный алгоритм хранения. API должно определить, как ключ связывается с аккаунтом или арендатором, сколько живёт, что происходит при одновременных запросах и какой ответ получает повтор.
Ключ относится к смысловой операции, а не к соединению. Один retry может получить новый Request ID, но должен сохранить тот же ключ операции. Если ключ order-42 уникален только внутри магазина, тот же текст в другом магазине допустим. После истечения TTL ключ может начать новую операцию. Эти границы нужно записать в контракте, иначе дедупликация будет случайной.
Надёжный слой идемпотентности хранит не одну строку, а связь между ключом, отпечатком входных параметров и исходом выполнения. Повтор с тем же ключом и другим телом должен получить конфликт до нового побочного эффекта. Иначе клиент может принять старый результат за подтверждение новой команды.
\n| Вопрос контракта | Проверяемое решение | Риск при пропуске |
|---|---|---|
| Область уникальности | Аккаунт, tenant или вся система | Чужая операция блокирует ключ |
| Срок хранения | TTL не короче окна допустимого повтора | Поздний retry создаёт дубль |
| Содержимое повтора | Тот же результат, статус или явная ошибка | Клиент повторяет вслепую |
| Другое тело | Сравнение отпечатка и конфликт | Один ключ скрывает другую команду |
| Конкуренция | Атомарная запись состояния «выполняется» | Два процесса создают два ресурса |
Правила хранения ошибок зависят от API. Например, Stripe сохраняет статус и тело первого результата, включая ошибку 500, а запросы, которые не прошли валидацию до начала выполнения, не получают сохранённого идемпотентного результата. Это полезный пример частного контракта, но не правило для любого сервиса. При проектировании нужно отдельно решить, можно ли повторить ошибку валидации, как освободить зависший ключ и как восстановить состояние после сбоя.
\nКод ниже можно сохранить в файл idempotency-demo.mjs и запустить командой node idempotency-demo.mjs. Сервер принимает локальные POST, сохраняет тело для сравнения и возвращает тот же id при повторе. Два разных тела с одним ключом получают 409. Пример намеренно хранит данные в Map одного процесса: он показывает поведение контракта, но не заменяет базу, транзакцию или аутентификацию.
import { createServer } from 'node:http';\n\nconst records = new Map();\nlet nextId = 1;\n\nconst server = createServer((request, response) => {\n if (request.method !== 'POST') {\n response.writeHead(405).end();\n return;\n }\n const key = request.headers['idempotency-key'];\n if (typeof key !== 'string' || key.length < 8) {\n response.writeHead(400).end('Idempotency-Key required');\n return;\n }\n let body = '';\n request.setEncoding('utf8');\n request.on('data', (chunk) => { body += chunk; });\n request.on('end', () => {\n const saved = records.get(key);\n if (saved && saved.body !== body) {\n response.writeHead(409).end('key reused with different body');\n return;\n }\n const result = saved || { body, id: nextId++, state: 'created' };\n records.set(key, result);\n response.writeHead(saved ? 200 : 201, { 'content-type': 'application/json' });\n response.end(JSON.stringify({ id: result.id, state: result.state }));\n });\n});\n\nserver.listen(0, '127.0.0.1', () => {\n console.log('listening on ' + server.address().port);\n});\nДва последовательных запроса с ключом order-42 вернут один id; запрос с тем же ключом и другим телом вернёт 409. В примере это обеспечивается одним циклом событий Node.js и памятью процесса. В production проверка ключа, тела и записи результата должна быть атомарной в общем хранилище. Иначе два экземпляра сервиса одновременно увидят отсутствующий ключ.
| Симптом | Что проверить | Безопасное действие |
|---|---|---|
| Timeout, ресурс уже есть | Состояние ресурса и operation key | Получить результат или повторить с тем же ключом |
| Каждый retry создаёт ресурс | Серверный контракт POST | Отключить автоматический retry или добавить дедупликацию |
| Один ключ даёт разные ответы | TTL, namespace и сохранённый результат | Зафиксировать правило повтора и истечения |
| Другое тело проходит | Тело или его отпечаток | Вернуть конфликт до побочного эффекта |
| После сбоя растёт очередь | Число попыток и remaining deadline | Ограничить backoff, attempts и общий бюджет |
Различайте причины отказа. HTTP 503 может сопровождаться Retry-After, но сам статус не доказывает, что upstream не успел записать данные. Gateway может вернуть 503 после завершения операции в зависимом сервисе. Сетевое исключение, отмена deadline и ответ посредника должны попадать в разные поля логов.
Повтор не должен передавать секреты и персональные данные в диагностические поля. Достаточно operation key, request ID, номера попытки, метода, endpoint без чувствительных параметров, статуса, длительности, причины решения и остатка общего времени. Тело запроса логируйте только по явной политике и с редактированием данных.
\nНе повторяйте запись, если сервер не обещает идемпотентность и нельзя отдельно проверить состояние. Не превращайте любой 5xx в разрешение на повтор. Даже если ответ выглядит временным, повтор небезопасен без знания прикладного эффекта.
\nBackoff и jitter уменьшают синхронный всплеск, но не исправляют двойную запись. Circuit breaker ограничивает давление на зависимость, но не сообщает, была ли команда принята. Общий deadline нужен для всей цепочки, а не только для каждого отдельного вызова: иначе три коротких таймаута растянутся и продолжат нагрузку после того, как пользователь уже ушёл.
\nОтдельно проверяйте операции с внешними эффектами. Идемпотентная запись заказа не делает автоматически однократной отправку письма или вызов платёжного провайдера. Для них нужен собственный ключ провайдера, транзакционный outbox, дедупликация обработчика или сверка состояния. Гарантию «ровно один раз» нельзя обещать всей цепочке, если хотя бы один участник не имеет такого контракта.
\nIdempotency-Key не является транзакцией. Нужны согласованное хранилище, уникальное ограничение, атомарный переход состояния и восстановление после сбоя между фиксацией результата и сохранением ответа. В многорегиональной системе область уникальности должна охватывать все узлы, которые принимают одну операцию.
\nTTL выбирают по максимальному времени повторной доставки, задержке очередей и бизнес-риску. Слишком короткий срок снова разрешит дубль; слишком длинный увеличит хранилище и может удерживать старые параметры. Размер и способ вычисления отпечатка, правила приватности и ключ шифрования — отдельные решения, которых нет в учебном примере.
\nЕсли результатом является асинхронная задача, сохранённый HTTP-ответ означает только принятие команды. Клиенту нужен статус операции по тому же ключу или resource ID. Если провайдер не умеет повторять безопасно, система должна выбрать сверку, компенсацию или ручную обработку, а не маскировать неопределённость новым POST.
\nКонтракт готов, если для одного operation key воспроизводятся четыре сценария: успешный первый запрос, timeout после записи, повтор с тем же телом и повтор с другим телом. В первых двух сценариях остаются один ресурс и один побочный эффект. Третий возвращает тот же результат или явно определённый статус. Четвёртый возвращает конфликт до новой записи. Все попытки видны по operation key, request ID и номеру попытки, а общий deadline ограничивает цепочку.
\nЕсли хотя бы один сценарий нельзя доказать тестом или наблюдаемым сигналом, автоматический retry остаётся предположением. В таком месте его нужно отключить до появления контракта и процедуры сверки.
\n