Files
progcode/editorial/agent-rewrites/017.json
T

8 lines
23 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 и короткий timeout такой контракт не заменяют.</p>\n<h2>Timeout сообщает о попытке, а не о результате</h2>\n<p>Транспорт управляет соединением и передачей данных, но не знает, зафиксирована ли транзакция в базе. HTTP описывает метод, статус и заголовки, однако не видит внутреннюю границу между записью и отправкой ответа. Если процесс сохранил заказ и упал перед ответом, клиент получает неопределённый исход: операция могла завершиться.</p>\n<p>RFC 9110 называет идемпотентным такой метод, у которого несколько одинаковых запросов имеют тот же предполагаемый эффект, что и один. В стандарте к ним относятся безопасные методы, PUT и DELETE. RFC отдельно предупреждает: клиенту не следует автоматически повторять неидемпотентный метод, если он не знает прикладную семантику или не умеет проверить, что исходный запрос не применился.</p>\n<p>Это не означает, что любой GET можно повторять бесконечно. Чтение не добавляет запись, но каждый вызов потребляет квоту и нагрузку. Повтор всё равно ограничивают числом попыток и общим временем. И наоборот, POST не обречён на дубль: конкретный API может сделать его идемпотентным через ключ, предварительное условие или другую часть контракта.</p>\n<table><caption>Идентификаторы отвечают на разные вопросы</caption><thead><tr><th scope=\"col\">Идентификатор</th><th scope=\"col\">Кто создаёт</th><th scope=\"col\">Что связывает</th><th scope=\"col\">Чего не доказывает</th></tr></thead><tbody><tr><th scope=\"row\">Connection ID</th><td>транспорт</td><td>пакеты одного соединения</td><td>результат бизнес-операции</td></tr><tr><th scope=\"row\">Request ID</th><td>клиент или gateway</td><td>одну попытку и её логи</td><td>отсутствие повторного эффекта</td></tr><tr><th scope=\"row\">Idempotency-Key</th><td>клиент для операции</td><td>несколько попыток одного действия</td><td>атомарность без серверной реализации</td></tr><tr><th scope=\"row\">Resource ID</th><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>Для POST сервер часто принимает заголовок <code>Idempotency-Key</code>. Это не универсальная гарантия HTTP и не стандартный алгоритм хранения. API должно определить, как ключ связывается с аккаунтом или арендатором, сколько живёт, что происходит при одновременных запросах и какой ответ получает повтор.</p>\n<p>Ключ относится к смысловой операции, а не к соединению. Один retry может получить новый Request ID, но должен сохранить тот же ключ операции. Если ключ <code>order-42</code> уникален только внутри магазина, тот же текст в другом магазине допустим. После истечения TTL ключ может начать новую операцию. Эти границы нужно записать в контракте, иначе дедупликация будет случайной.</p>\n<h2>Контракт ключа: вход, результат и конкуренция</h2>\n<p>Надёжный слой идемпотентности хранит не одну строку, а связь между ключом, отпечатком входных параметров и исходом выполнения. Повтор с тем же ключом и другим телом должен получить конфликт до нового побочного эффекта. Иначе клиент может принять старый результат за подтверждение новой команды.</p>\n<table><caption>Минимальные решения, которые должен зафиксировать API</caption><thead><tr><th scope=\"col\">Вопрос контракта</th><th scope=\"col\">Проверяемое решение</th><th scope=\"col\">Риск при пропуске</th></tr></thead><tbody><tr><th scope=\"row\">Область уникальности</th><td>Аккаунт, tenant или вся система</td><td>Чужая операция блокирует ключ</td></tr><tr><th scope=\"row\">Срок хранения</th><td>TTL не короче окна допустимого повтора</td><td>Поздний retry создаёт дубль</td></tr><tr><th scope=\"row\">Содержимое повтора</th><td>Тот же результат, статус или явная ошибка</td><td>Клиент повторяет вслепую</td></tr><tr><th scope=\"row\">Другое тело</th><td>Сравнение отпечатка и конфликт</td><td>Один ключ скрывает другую команду</td></tr><tr><th scope=\"row\">Конкуренция</th><td>Атомарная запись состояния «выполняется»</td><td>Два процесса создают два ресурса</td></tr></tbody></table>\n<p>Правила хранения ошибок зависят от API. Например, Stripe сохраняет статус и тело первого результата, включая ошибку 500, а запросы, которые не прошли валидацию до начала выполнения, не получают сохранённого идемпотентного результата. Это полезный пример частного контракта, но не правило для любого сервиса. При проектировании нужно отдельно решить, можно ли повторить ошибку валидации, как освободить зависший ключ и как восстановить состояние после сбоя.</p>\n<h2>Учебный сервер с проверкой тела</h2>\n<p>Код ниже можно сохранить в файл <code>idempotency-demo.mjs</code> и запустить командой <code>node idempotency-demo.mjs</code>. Сервер принимает локальные POST, сохраняет тело для сравнения и возвращает тот же <code>id</code> при повторе. Два разных тела с одним ключом получают 409. Пример намеренно хранит данные в Map одного процесса: он показывает поведение контракта, но не заменяет базу, транзакцию или аутентификацию.</p>\n<pre><code>import { createServer } from 'node:http';\n\nconst records = new Map();\nlet nextId = 1;\n\nconst server = createServer((request, response) =&gt; {\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 &lt; 8) {\n response.writeHead(400).end('Idempotency-Key required');\n return;\n }\n let body = '';\n request.setEncoding('utf8');\n request.on('data', (chunk) =&gt; { body += chunk; });\n request.on('end', () =&gt; {\n const saved = records.get(key);\n if (saved &amp;&amp; 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', () =&gt; {\n console.log('listening on ' + server.address().port);\n});</code></pre>\n<p>Два последовательных запроса с ключом <code>order-42</code> вернут один <code>id</code>; запрос с тем же ключом и другим телом вернёт 409. В примере это обеспечивается одним циклом событий Node.js и памятью процесса. В production проверка ключа, тела и записи результата должна быть атомарной в общем хранилище. Иначе два экземпляра сервиса одновременно увидят отсутствующий ключ.</p>\n<h2>Повторять ли запрос: симптом → проверка → действие</h2>\n<table><caption>Диагностическая матрица для решения о retry</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Что проверить</th><th scope=\"col\">Безопасное действие</th></tr></thead><tbody><tr><th scope=\"row\">Timeout, ресурс уже есть</th><td>Состояние ресурса и operation key</td><td>Получить результат или повторить с тем же ключом</td></tr><tr><th scope=\"row\">Каждый retry создаёт ресурс</th><td>Серверный контракт POST</td><td>Отключить автоматический retry или добавить дедупликацию</td></tr><tr><th scope=\"row\">Один ключ даёт разные ответы</th><td>TTL, namespace и сохранённый результат</td><td>Зафиксировать правило повтора и истечения</td></tr><tr><th scope=\"row\">Другое тело проходит</th><td>Тело или его отпечаток</td><td>Вернуть конфликт до побочного эффекта</td></tr><tr><th scope=\"row\">После сбоя растёт очередь</th><td>Число попыток и remaining deadline</td><td>Ограничить backoff, attempts и общий бюджет</td></tr></tbody></table>\n<p>Различайте причины отказа. HTTP 503 может сопровождаться <code>Retry-After</code>, но сам статус не доказывает, что upstream не успел записать данные. Gateway может вернуть 503 после завершения операции в зависимом сервисе. Сетевое исключение, отмена deadline и ответ посредника должны попадать в разные поля логов.</p>\n<p>Повтор не должен передавать секреты и персональные данные в диагностические поля. Достаточно operation key, request ID, номера попытки, метода, endpoint без чувствительных параметров, статуса, длительности, причины решения и остатка общего времени. Тело запроса логируйте только по явной политике и с редактированием данных.</p>\n<h2>Когда автоматический retry запрещён</h2>\n<p>Не повторяйте запись, если сервер не обещает идемпотентность и нельзя отдельно проверить состояние. Не превращайте любой 5xx в разрешение на повтор. Даже если ответ выглядит временным, повтор небезопасен без знания прикладного эффекта.</p>\n<p>Backoff и jitter уменьшают синхронный всплеск, но не исправляют двойную запись. Circuit breaker ограничивает давление на зависимость, но не сообщает, была ли команда принята. Общий deadline нужен для всей цепочки, а не только для каждого отдельного вызова: иначе три коротких таймаута растянутся и продолжат нагрузку после того, как пользователь уже ушёл.</p>\n<p>Отдельно проверяйте операции с внешними эффектами. Идемпотентная запись заказа не делает автоматически однократной отправку письма или вызов платёжного провайдера. Для них нужен собственный ключ провайдера, транзакционный outbox, дедупликация обработчика или сверка состояния. Гарантию «ровно один раз» нельзя обещать всей цепочке, если хотя бы один участник не имеет такого контракта.</p>\n<h2>Порядок внедрения и тест потери ответа</h2>\n<ol><li>Назовите доменное действие и каждый побочный эффект: создание заказа, списание, письмо или изменение лимита.</li><li>Опишите состояния до, во время и после выполнения. Для timeout добавьте запрос чтения или сверки без нового эффекта.</li><li>Проверьте HTTP-метод и прикладную реализацию. Не делайте вывод только из POST, 503 или наличия Request ID.</li><li>Для записи задайте формат ключа, namespace, TTL, тело или его отпечаток и правило для повторного результата.</li><li>Сделайте тест: принять запись, намеренно потерять ответ, повторить запрос с тем же ключом и сравнить ресурс.</li><li>Сделайте тест параллельных запросов с одним ключом. Должен появиться один ресурс и один побочный эффект.</li><li>Сделайте тест того же ключа с другим телом. Ответ должен быть конфликтом до новой записи.</li><li>Добавьте общий deadline, лимит попыток, backoff с jitter и поля operation key, request ID и attempt.</li><li>Проверьте отрицательный путь: без контракта клиент останавливается и передаёт неопределённый исход на разбор.</li></ol>\n<h2>Ограничения применимости</h2>\n<p>Idempotency-Key не является транзакцией. Нужны согласованное хранилище, уникальное ограничение, атомарный переход состояния и восстановление после сбоя между фиксацией результата и сохранением ответа. В многорегиональной системе область уникальности должна охватывать все узлы, которые принимают одну операцию.</p>\n<p>TTL выбирают по максимальному времени повторной доставки, задержке очередей и бизнес-риску. Слишком короткий срок снова разрешит дубль; слишком длинный увеличит хранилище и может удерживать старые параметры. Размер и способ вычисления отпечатка, правила приватности и ключ шифрования — отдельные решения, которых нет в учебном примере.</p>\n<p>Если результатом является асинхронная задача, сохранённый HTTP-ответ означает только принятие команды. Клиенту нужен статус операции по тому же ключу или resource ID. Если провайдер не умеет повторять безопасно, система должна выбрать сверку, компенсацию или ручную обработку, а не маскировать неопределённость новым POST.</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://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/\" target=\"_blank\" rel=\"noopener\">AWS Builders’ Library: Making retries safe with idempotent APIs</a> — поздние запросы, различие request ID и намерения, идемпотентность в составных операциях.</li><li><a href=\"https://docs.stripe.com/api/idempotent_requests\" target=\"_blank\" rel=\"noopener\">Stripe API: Idempotent requests</a> — пример частного контракта хранения ответа, сравнения параметров и TTL.</li><li><a href=\"https://docs.cloud.google.com/storage/docs/retry-strategy\" target=\"_blank\" rel=\"noopener\">Google Cloud Storage: Retry strategy</a> — разделение retryable-ответа, идемпотентности и условно безопасных операций.</li></ul>"
}