Files
progcode/editorial/agent-rewrites/017.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
16 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 17,
"slug": "editorial-2027-07-mechanism-reliability-capstone",
"title": "Retry после timeout: как не повторить бизнес-операцию дважды",
"excerpt": "Timeout сообщает о потерянном ответе, но не о результате записи. Идемпотентный контракт связывает повторы одной операции и не даёт сетевому сбою превратиться в дубль.",
"contentHtml": "<p>Клиент отправляет запрос на создание заказа и ждёт ответа. Через пять секунд он получает timeout. Пользователь нажимает «Повторить», а клиент автоматически отправляет тот же POST ещё раз. В системе появляются два заказа. Для платежа цена выше: можно получить двойное списание, повторное письмо или две отгрузки.</p>\n<p>Timeout говорит только о том, что клиент не получил ответ в отведённый срок. Сервер мог не принять запрос, мог отклонить его или уже записать результат, пока ответ терялся между сервисами. Поэтому retry нельзя строить вокруг одной ошибки сети. Нужно знать, какой эффект имеет операция и как сервер распознаёт повтор.</p>\n<p><strong>Тезис:</strong> надёжный retry начинается с контракта бизнес-операции. Для чтения достаточно ограниченного повтора с общим deadline. Для записи нужен идемпотентный метод или ключ операции, который сервер проверяет атомарно вместе с результатом. Один request id, повторная отправка POST и надежда на быстрый ответ такой контракт не заменяют.</p>\n<h2>Что именно скрывает timeout</h2>\n<p>Транспорт доставляет байты и сообщает о состоянии соединения. Он не знает, зафиксирована ли транзакция в базе. HTTP описывает метод, статус и заголовки, но не видит внутреннюю границу между записью и отправкой ответа. Если процесс записал заказ, а затем упал до ответа, клиент получает неопределённый исход: операция могла завершиться.</p>\n<p>Для GET повтор обычно не создаёт новый объект. Для DELETE повтор может вернуть другой статус, но ожидаемое состояние остаётся удалённым. POST по умолчанию не даёт такой гарантии: сервер может создать новый ресурс при каждом запросе. Нельзя выводить безопасность повтора из короткого имени метода. Нужно проверить доменное действие.</p>\n<table><caption>Четыре идентификатора и их границы</caption><thead><tr><th>Идентификатор</th><th>Кто создаёт</th><th>Что связывает</th><th>Чего не гарантирует</th></tr></thead><tbody><tr><td>Connection ID</td><td>транспорт</td><td>пакеты одного соединения</td><td>результат бизнес-операции</td></tr><tr><td>Request ID</td><td>клиент или gateway</td><td>одну попытку и её логи</td><td>отсутствие повторного эффекта</td></tr><tr><td>Idempotency-Key</td><td>клиент для операции</td><td>несколько попыток одного действия</td><td>атомарность, если сервер её не реализует</td></tr><tr><td>Resource ID</td><td>доменный сервис</td><td>созданный объект</td><td>связь двух попыток без контракта</td></tr></tbody></table>\n<figure><img src=\"/assets/editorial/2027/reliability-capstone-2027-recovery-evidence-matrix.svg\" alt=\"Матрица границ надёжности: транспорт, HTTP-метод, ключ операции, запись и подтверждение результата.\"><figcaption>Матрица показывает, какой слой отвечает на конкретный вопрос. Она не доказывает доставку отдельного запроса.</figcaption></figure>\n<h2>Идемпотентность означает один эффект</h2>\n<p>Операция идемпотентна, если один или несколько одинаковых запросов дают тот же ожидаемый эффект, что и один запрос. Ответы при этом могут отличаться. Первый вызов может вернуть 201, повтор — сохранённый 200 или 409 по правилам API. Проверять нужно состояние и контракт, а не только код ответа.</p>\n<p>Для создания ресурса сервер может принять Idempotency-Key. Он сохраняет связь между ключом, параметрами операции и результатом. Повтор с тем же ключом возвращает сохранённый результат или определённую ошибку. Повтор с тем же ключом, но другим телом должен завершаться конфликтом. Иначе старый результат можно ошибочно выдать за результат новой команды.</p>\n<p>Ключ относится к смысловой операции, а не к соединению. Его область уникальности и срок хранения должны быть явными. Ключ <code>order-42</code> может быть уникальным в пределах одного клиента, магазина или всей системы. После истечения срока тот же ключ может стать новой операцией. Это часть API-контракта.</p>\n<h2>Учебный сервер с защитой от дубля</h2>\n<p>Ниже — ограниченный учебный пример на Node.js. Он показывает только идею: два POST с одним ключом получают один номер записи. Данные хранятся в Map в памяти процесса. Пример не заменяет транзакцию, распределённое хранилище, аутентификацию и проверку тела запроса.</p>\n<pre><code>const 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'));</code></pre>\n<p>В учебном запуске оба ответа содержат один <code>id</code>. Это ожидаемое свойство примера, а не результат работы реального сервиса. В настоящей системе проверка ключа и создание записи должны проходить под атомарным ограничением. Два процесса не должны одновременно увидеть отсутствующий ключ и создать два ресурса.</p>\n<p>Хранилище должно запоминать параметры операции или их отпечаток. Если первый запрос создаёт заказ на 100 рублей, а повтор с тем же ключом просит 10 000 рублей, сервер не должен молча отдавать старый результат. Он должен вернуть конфликт до нового побочного эффекта. Успешный результат, ошибка валидации и ошибка сервера требуют отдельных правил хранения.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<table><caption>Диагностическая матрица для повторов</caption><thead><tr><th>Симптом</th><th>Причина</th><th>Проверка</th><th>Действие</th></tr></thead><tbody><tr><td>Timeout, но ресурс уже есть</td><td>Ответ потерялся после записи</td><td>Сопоставить trace, request id и состояние ресурса</td><td>Повторить только с тем же ключом или запросить результат по resource id</td></tr><tr><td>Каждый retry создаёт новый ресурс</td><td>POST не имеет дедупликации</td><td>Отправить два запроса с одним ключом и сравнить записи</td><td>Добавить контракт ключа или запретить автоматический retry</td></tr><tr><td>Один ключ даёт разные ответы</td><td>Ключ не связан с результатом или истёк</td><td>Проверить TTL и область уникальности</td><td>Зафиксировать срок, namespace и правило истечения</td></tr><tr><td>Повтор с другим телом проходит</td><td>Сервер хранит только строку ключа</td><td>Сравнить отпечатки тел</td><td>Вернуть конфликт до побочного эффекта</td></tr><tr><td>После сбоя растёт очередь</td><td>Retry не учитывает deadline</td><td>Посчитать попытки, задержки и время отмены</td><td>Ограничить повторы, backoff и бюджет времени</td></tr></tbody></table>\n<h2>Когда повторять нельзя</h2>\n<p>Не повторяйте запись, если сервер не обещает идемпотентность и нельзя отдельно проверить состояние. Это отрицательный путь. Лучше вернуть неопределённый результат и передать операцию на доменную проверку, чем незаметно создать второй эффект.</p>\n<p>Не превращайте любой 5xx в разрешение на повтор. 503 может сопровождаться <code>Retry-After</code>, но его наличие не доказывает, что запрос не был принят. Gateway может вернуть свой 503 после выполнения upstream-операции. Сетевое исключение, отмена deadline и ответ посредника должны различаться в логах.</p>\n<p>Не повторяйте после истечения общего deadline. Отдельные таймауты на каждый вызов могут растянуть цепочку на минуты и создать лавину в зависимостях. Backoff снижает частоту, но не исправляет небезопасный эффект. Circuit breaker ограничивает давление, но не сообщает, была ли запись принята.</p>\n<h2>Порядок проверки</h2>\n<ol><li>Назовите доменное действие и его побочный эффект: создание заказа, списание, отправка письма или изменение лимита.</li><li>Определите состояние после timeout и запрос, который проверит его без нового побочного эффекта.</li><li>Проверьте контракт HTTP и серверную реализацию. Не делайте вывод только из метода или статуса.</li><li>Для записи задайте формат, область уникальности и срок жизни Idempotency-Key.</li><li>Сделайте тест потери ответа после записи. Повтор должен вернуть тот же результат или явный конфликт.</li><li>Сделайте тест повторного ключа с другим телом. Вторая команда не должна менять состояние.</li><li>Добавьте общий deadline, лимит попыток, backoff и поля operation key, request id и attempt.</li><li>Проверьте отрицательный путь: при отсутствии контракта клиент останавливается.</li></ol>\n<h2>Ограничения</h2>\n<p>Idempotency-Key не решает конкуренцию сам по себе. Нужны атомарная запись, согласованное хранилище и правило восстановления после сбоя между фиксацией результата и сохранением ответа. В многорегиональной системе область уникальности должна охватывать все узлы, которые принимают операцию.</p>\n<p>Срок хранения ключей выбирают по максимальному времени повторной доставки и бизнес-риску. Слишком короткий TTL снова разрешит дубль. Слишком длинный TTL увеличит хранилище. Платёжные и юридически значимые операции требуют отдельного доменного контракта, аудита и проверки состояния.</p>\n<p>Учебный сервер не моделирует рестарт, несколько процессов, транзакцию базы, частичный ответ и истечение TTL. Он полезен только для проверки различия между попыткой и операцией. Не переносите его Map в production без этих механизмов.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Механизм готов, если для одного operation key можно воспроизвести четыре исхода: успешный первый запрос, timeout после записи, повтор с тем же телом и повтор с другим телом. В первых двух случаях состояние содержит один ресурс и один побочный эффект. Третий случай возвращает тот же результат или согласованный статус. Четвёртый возвращает конфликт до новой записи. Все попытки видны по operation key, request id и номеру попытки, а общий deadline ограничивает цепочку.</p>\n<p>Если хотя бы один исход нельзя проверить тестом или наблюдаемым сигналом, retry остаётся предположением. В таком месте автоматический повтор нужно отключить до появления контракта.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html#name-idempotent-methods\" target=\"_blank\" rel=\"noopener\">IETF RFC 9110, раздел 9.2.2 «Idempotent Methods»</a></li><li><a href=\"https://docs.stripe.com/api/idempotent_requests\" target=\"_blank\" rel=\"noopener\">Stripe API: Idempotent requests</a></li></ul>"
}