diff --git a/editorial/agent-rewrites/272.json b/editorial/agent-rewrites/272.json index d40ebd6..f0079e5 100644 --- a/editorial/agent-rewrites/272.json +++ b/editorial/agent-rewrites/272.json @@ -3,5 +3,5 @@ "slug": "editorial-2020-06-mechanism-retry-idempotency", "title": "Идемпотентный retry: как не выполнить одну команду дважды", "excerpt": "Клиент получил timeout, но сервер мог уже создать результат. Разбираем scope ключа, fingerprint payload, конкурентный запрос, сохранённый ответ и границу внешнего эффекта.", - "contentHtml": "
Симптом знакомый: пользователь нажал «Создать», увидел timeout и нажал ещё раз. В базе появились две заявки. Иногда дубль возникает без второго клика: клиент повторяет POST после разрыва соединения, пока первый обработчик ещё работает. Цена ошибки зависит от операции. Две строки в черновике можно удалить. Два платежа, письма или заказа требуют ручного разбора и возврата денег.
\nTimeout сообщает только об ответе, которого клиент не увидел. Он не доказывает, что сервер не принял запрос. Между принятием команды и доставкой ответа есть база, worker, proxy и сеть. Любой участок может завершить работу, а следующий участок — потерять результат.
\nТезис статьи простой: retry безопасен только тогда, когда сервис связывает повтор с тем же intent и хранит решение операции. Для этого нужны scope ключа, fingerprint значимых данных, атомарный захват ключа, состояние in_progress и сохранённый terminal-ответ. Повтор не должен снова запускать бизнес-обработчик.
Идемпотентность не означает «ответ всегда одинаковый» и не означает «в системе не будет побочных эффектов». В HTTP это свойство намеренного эффекта метода: несколько одинаковых запросов должны дать тот же эффект, что один. RFC 9110 относит к идемпотентным PUT, DELETE и безопасные методы. POST сам по себе таким свойством не обладает.
\nДля POST сервис может добавить собственный контракт. Клиент создаёт ключ на один пользовательский intent и сохраняет его до получения окончательного результата. При сетевом сбое он отправляет тот же payload с тем же ключом. Сервер узнаёт повтор, возвращает прежний результат и не создаёт новый объект.
\nКлюч относится к действию, а не к сетевой попытке. requestId может меняться на каждом HTTP-проходе. Idempotency-Key должен оставаться тем же для одного intent. Если на retry сгенерировать новый ключ, сервер увидит новую команду и защита не сработает.
Один ключ не обязан быть уникальным во всей системе. Сервис задаёт scope. В учебном примере это actor_id, имя операции и idem_key. Одинаковая строка ключа у двух пользователей не должна открыть один результат. Одинаковая строка в другой операции не должна заблокировать её.
Одного scope мало. Клиент может по ошибке повторно использовать ключ с другим payload. Поэтому при первом запросе сервис вычисляет fingerprint по каноническим значимым полям. Повтор с тем же ключом и тем же fingerprint — кандидат на replay. Тот же ключ с другим fingerprint — конфликт. Обработчик не запускается.
\nКанонизация входит в API-контракт. Нужно заранее определить, какие поля влияют на эффект. Порядок ключей JSON, пробелы и display-поля не должны случайно превращать тот же intent в другой. Поля авторизации, которые задают scope, не смешивают с payload fingerprint: субъект проверяется отдельно.
\nМинимальная запись хранит scope, fingerprint, состояние, срок действия и данные replay. Для terminal-состояния достаточно статуса, безопасного тела ответа и идентификатора результата. Сохранять в этой таблице токены, cookies и полный запрос не нужно. Слишком бедная запись тоже опасна: если в ней нет результата, повтор снова вынужден угадывать.
\n| Поле | Задача | Что проверить |
|---|---|---|
actor_id + operation + idem_key | Описывает scope и захватывает intent | В базе есть составное уникальное ограничение |
payload_hash | Отличает retry от подмены payload | Значимое изменение даёт конфликт до обработчика |
state | Разделяет in_progress и terminal | Параллельный запрос не начинает второй handler |
status_code + response_body | Возвращает тот же наблюдаемый результат | Готовый retry получает тот же result ID |
expires_at | Задаёт окно повторного распознавания | TTL длиннее согласованного retry budget |
Сервис не должен считать запись seen=true достаточной. Такой флаг не отвечает на три вопроса: обработчик ещё выполняется, операция завершилась ошибкой или результат уже сохранён? Разные ответы требуют разных действий. in_progress нельзя молча трактовать как новый запуск.
Проверка в памяти процесса не защищает второй pod, worker или рестарт. Уникальность должна жить в устойчивом хранилище, где конкурирующие запросы видят один результат. Первый запрос вставляет запись в scope. Только тот, кто успешно вставил строку, получает право вызвать бизнес-обработчик.
\nBEGIN;\n\nINSERT INTO idempotency_keys (\n actor_id, operation, idem_key, payload_hash, state, expires_at\n) VALUES ($1, $2, $3, $4, 'in_progress', $5)\nON CONFLICT (actor_id, operation, idem_key) DO NOTHING;\n\n-- если вставлена строка: этот запрос владеет обработкой\n-- если строка уже была: читаем state и payload_hash\n\nCOMMIT;\nSQL — учебная иллюстрация, а не готовая схема для конкретного проекта. Драйвер должен надёжно различать вставку и конфликт. После конфликта сервис читает существующую запись и выбирает ветку. Он не вызывает основной обработчик до этого решения.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Первый запрос вставил запись | Ключ свободен в заданном scope | Текущая транзакция получила право обработки | Запустить один handler и завершить запись terminal-результатом |
Повтор видит completed | Первый запрос уже сохранил решение | Есть status, body и result ID | Вернуть сохранённый HTTP-ответ без нового эффекта |
Повтор видит in_progress | Первый handler ещё не завершил договор | Terminal-записи нет, ключ занят | Вернуть документированный conflict или статус ожидания; не запускать второй handler |
| Тот же ключ имеет другой hash | Ключ повторно использовали для другого intent | Сравнить hash до business action | Вернуть 409 Conflict с машинной причиной; попросить новый intent |
| Два result ID имеют один scope/key | Захват неатомарен или часть writers обошла контракт | Сопоставить SQL, pod, время и внешние вызовы | Остановить blind retry и исправить общую границу конкурентного доступа |
Для in_progress API выбирает одну семантику и документирует её. Можно вернуть конфликт с полем code=operation_in_progress. Можно дать клиенту endpoint проверки статуса. Нельзя скрыть этот случай за обычным retry без ключа: он снова создаст гонку.
Обработчик должен завершить запись тем результатом, который увидит клиент. Если объект и запись ключа лежат в одной базе, их можно сохранить одной транзакцией. После commit повтор найдёт оба факта и вернёт status и result ID. Если процесс упал до commit, следующий запрос увидит незавершённую транзакцию и сможет начать обработку по правилам базы.
\nasync function createOnce(input, key, actorId) {\n const hash = fingerprint(input);\n const claim = await reserve(actorId, 'demo.create', key, hash);\n\n if (claim.kind === 'replay') return claim.response;\n if (claim.kind === 'conflict') throw new HttpError(409, 'key_payload_mismatch');\n if (claim.kind === 'in_progress') throw new HttpError(409, 'operation_in_progress');\n\n try {\n const result = await createResultAndFinishAtomically(input, claim.id);\n return result.response;\n } catch (error) {\n await markTerminalFailure(claim.id, safeError(error));\n throw error;\n }\n}\nПсевдокод показывает порядок, но не обещает конкретный статус ошибки, библиотеку базы или способ восстановления. На практике нужно решить, какие ошибки считаются terminal. Если обработчик не стартовал из-за валидации, ключ можно не резервировать или сохранить отказ по отдельному контракту. Если внешний эффект уже принят, удалять запись после исключения опасно.
\nЛокальная таблица не делает внешний вызов exactly once. Сервис может отправить запрос поставщику, получить результат, а затем упасть до сохранения completed. После рестарта запись выглядит зависшей, хотя внешний объект уже создан. Очистка ключа и новый вызов могут создать дубль.
Для внешнего эффекта нужен второй договор. Используйте устойчивый business ID, детерминированное имя объекта, idempotency key поставщика или endpoint проверки состояния. Восстановление in_progress должно сначала узнать, был ли эффект принят, и только потом решать, допустим ли повтор. Если такой проверки нет, автоматический retry нужно остановить и передать неопределённый исход в безопасный маршрут.
Это отрицательный путь механизма. Идемпотентность не отменяет уже отправленное письмо и не откатывает платёж в другой системе. Она лишь даёт локальную запись, с которой можно продолжить расследование. Граница транзакции должна быть явно отмечена в архитектуре.
\nrequestId для каждой доставки, один Idempotency-Key для одного intent.Ключ не заменяет аутентификацию, авторизацию, валидацию, rate limit и защиту от перегрузки. Он не спасает, если разные writers используют разные scope или один путь вызывает внешний эффект до резервирования записи. Он также не гарантирует exactly once между двумя независимыми системами.
\nНельзя выбрать TTL по удобству таблицы. Он должен учитывать максимальный retry budget клиента, задержку proxy и время, после которого API запрещает поздний повтор. Слишком короткий TTL превращает поздний retry в новый эффект. Слишком длинный TTL удерживает результат и чувствительные данные без необходимости.
\nВсе адреса, SQL, ключи, result ID и ответы ниже учебные. Реальный endpoint, база, proxy, нагрузка и платёжная интеграция здесь не запускались. Перед внедрением нужно проверить конкурентные транзакции, размер response body, правила хранения данных и поведение каждого внешнего поставщика.
\nМеханизм готов, когда два конкурентных запроса с одним scope и ключом создают один effect; повтор после искусственно потерянного ответа возвращает сохранённый result ID; другой payload с тем же ключом получает conflict до обработчика; зависший внешний вызов имеет отдельный статусный маршрут; а TTL не открывает неоговорённый поздний retry. Эти условия должны быть проверены интеграционным тестом в выбранном стеке и видны в безопасных логах.
\nСимптом знакомый: пользователь нажал «Создать», увидел timeout и нажал ещё раз. В базе появились две заявки. Иногда дубль возникает без второго клика: клиент повторяет POST после разрыва соединения, пока первый обработчик ещё работает. Цена ошибки зависит от операции. Две строки в черновике можно удалить. Два платежа, письма или заказа требуют ручного разбора и возврата денег.
\nTimeout сообщает только об ответе, которого клиент не увидел. Он не доказывает, что сервер не принял запрос. Между принятием команды и доставкой ответа есть база, worker, proxy и сеть. Любой участок может завершить работу, а следующий участок — потерять результат.
\nТезис статьи простой: retry безопасен только тогда, когда сервис связывает повтор с тем же intent и хранит решение операции. Для этого нужны scope ключа, fingerprint значимых данных, атомарный захват ключа, состояние in_progress и сохранённый terminal-ответ. Повтор не должен снова запускать бизнес-обработчик.
Идемпотентность не означает «ответ всегда одинаковый» и не означает «в системе не будет побочных эффектов». В HTTP это свойство намеренного эффекта метода: несколько одинаковых запросов должны дать тот же эффект, что один. RFC 9110 относит к идемпотентным PUT, DELETE и безопасные методы. POST сам по себе таким свойством не обладает.
\nДля POST сервис может добавить собственный контракт. Клиент создаёт ключ на один пользовательский intent и сохраняет его до получения окончательного результата. При сетевом сбое он отправляет тот же payload с тем же ключом. Сервер узнаёт повтор, возвращает прежний результат и не создаёт новый объект.
\nКлюч относится к действию, а не к сетевой попытке. requestId может меняться на каждом HTTP-проходе. Idempotency-Key должен оставаться тем же для одного intent. Если на retry сгенерировать новый ключ, сервер увидит новую команду и защита не сработает.
Один ключ не обязан быть уникальным во всей системе. Сервис задаёт scope. В учебном примере это actor_id, имя операции и idem_key. Одинаковая строка ключа у двух пользователей не должна открыть один результат. Одинаковая строка в другой операции не должна заблокировать её.
Одного scope мало. Клиент может по ошибке повторно использовать ключ с другим payload. Поэтому при первом запросе сервис вычисляет fingerprint по каноническим значимым полям. Повтор с тем же ключом и тем же fingerprint — кандидат на replay. Тот же ключ с другим fingerprint — конфликт. Обработчик не запускается.
\nКанонизация входит в API-контракт. Нужно заранее определить, какие поля влияют на эффект. Порядок ключей JSON, пробелы и display-поля не должны случайно превращать тот же intent в другой. Поля авторизации, которые задают scope, не смешивают с payload fingerprint: субъект проверяется отдельно.
\nМинимальная запись хранит scope, fingerprint, состояние, срок действия и данные replay. Для terminal-состояния достаточно статуса, безопасного тела ответа и идентификатора результата. Сохранять в этой таблице токены, cookies и полный запрос не нужно. Слишком бедная запись тоже опасна: если в ней нет результата, повтор снова вынужден угадывать.
\n| Поле | Задача | Что проверить |
|---|---|---|
actor_id + operation + idem_key | Описывает scope и захватывает intent | В базе есть составное уникальное ограничение |
payload_hash | Отличает retry от подмены payload | Значимое изменение даёт конфликт до обработчика |
state | Разделяет in_progress и terminal | Параллельный запрос не начинает второй handler |
status_code + response_body | Возвращает тот же наблюдаемый результат | Готовый retry получает тот же result ID |
expires_at | Задаёт окно повторного распознавания | TTL длиннее согласованного retry budget |
Сервис не должен считать запись seen=true достаточной. Такой флаг не отвечает на три вопроса: обработчик ещё выполняется, операция завершилась ошибкой или результат уже сохранён? Разные ответы требуют разных действий. in_progress нельзя молча трактовать как новый запуск.
Проверка в памяти процесса не защищает второй pod, worker или рестарт. Уникальность должна жить в устойчивом хранилище, где конкурирующие запросы видят один результат. Первый запрос вставляет запись в scope. Только тот, кто успешно вставил строку, получает право вызвать бизнес-обработчик.
\nBEGIN;\n\nINSERT INTO idempotency_keys (\n actor_id, operation, idem_key, payload_hash, state, expires_at\n) VALUES ($1, $2, $3, $4, 'in_progress', $5)\nON CONFLICT (actor_id, operation, idem_key) DO NOTHING;\n\n-- если вставлена строка: этот запрос владеет обработкой\n-- если строка уже была: читаем state и payload_hash\n\nCOMMIT;\nSQL — учебная иллюстрация, а не готовая схема для конкретного проекта. Драйвер должен надёжно различать вставку и конфликт. После конфликта сервис читает существующую запись и выбирает ветку. Он не вызывает основной обработчик до этого решения.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Первый запрос вставил запись | Ключ свободен в заданном scope | Текущая транзакция получила право обработки | Запустить один handler и завершить запись terminal-результатом |
Повтор видит completed | Первый запрос уже сохранил решение | Есть status, body и result ID | Вернуть сохранённый HTTP-ответ без нового эффекта |
Повтор видит in_progress | Первый handler ещё не завершил договор | Terminal-записи нет, ключ занят | Вернуть документированный conflict или статус ожидания; не запускать второй handler |
| Тот же ключ имеет другой hash | Ключ повторно использовали для другого intent | Сравнить hash до business action | Вернуть 409 Conflict с машинной причиной; попросить новый intent |
| Два result ID имеют один scope/key | Захват неатомарен или часть writers обошла контракт | Сопоставить SQL, pod, время и внешние вызовы | Остановить blind retry и исправить общую границу конкурентного доступа |
Для in_progress API выбирает одну семантику и документирует её. Можно вернуть конфликт с полем code=operation_in_progress. Можно дать клиенту endpoint проверки статуса. Нельзя скрыть этот случай за обычным retry без ключа: он снова создаст гонку.
Обработчик должен завершить запись тем результатом, который увидит клиент. Если объект и запись ключа лежат в одной базе, их можно сохранить одной транзакцией. После commit повтор найдёт оба факта и вернёт status и result ID. Если резервирование ключа уже было зафиксировано отдельной транзакцией, падение оставит запись in_progress: следующий запрос не должен считать её свободной, а recovery-процесс сначала проверяет внешний эффект или действует по явным правилам lease/оператора. Если резервирование и изменение объекта входят в одну транзакцию базы, падение до commit откатит оба изменения; тогда новый запрос может заново захватить ключ. Граница этой транзакции должна быть частью контракта.
async function createOnce(input, key, actorId) {\n const hash = fingerprint(input);\n const claim = await reserve(actorId, 'demo.create', key, hash);\n\n if (claim.kind === 'replay') return claim.response;\n if (claim.kind === 'conflict') throw new HttpError(409, 'key_payload_mismatch');\n if (claim.kind === 'in_progress') throw new HttpError(409, 'operation_in_progress');\n\n try {\n const result = await createResultAndFinishAtomically(input, claim.id);\n return result.response;\n } catch (error) {\n await markTerminalFailure(claim.id, safeError(error));\n throw error;\n }\n}\nПсевдокод показывает порядок, но не обещает конкретный статус ошибки, библиотеку базы или способ восстановления. На практике нужно решить, какие ошибки считаются terminal. Если обработчик не стартовал из-за валидации, ключ можно не резервировать или сохранить отказ по отдельному контракту. Если внешний эффект уже принят, удалять запись после исключения опасно.
\nЛокальная таблица не делает внешний вызов exactly once. Сервис может отправить запрос поставщику, получить результат, а затем упасть до сохранения completed. После рестарта запись выглядит зависшей, хотя внешний объект уже создан. Очистка ключа и новый вызов могут создать дубль.
Для внешнего эффекта нужен второй договор. Используйте устойчивый business ID, детерминированное имя объекта, idempotency key поставщика или endpoint проверки состояния. Восстановление in_progress должно сначала узнать, был ли эффект принят, и только потом решать, допустим ли повтор. Если такой проверки нет, автоматический retry нужно остановить и передать неопределённый исход в безопасный маршрут.
Это отрицательный путь механизма. Идемпотентность не отменяет уже отправленное письмо и не откатывает платёж в другой системе. Она лишь даёт локальную запись, с которой можно продолжить расследование. Граница транзакции должна быть явно отмечена в архитектуре.
\nrequestId для каждой доставки, один Idempotency-Key для одного intent.Ключ не заменяет аутентификацию, авторизацию, валидацию, rate limit и защиту от перегрузки. Он не спасает, если разные writers используют разные scope или один путь вызывает внешний эффект до резервирования записи. Он также не гарантирует exactly once между двумя независимыми системами.
\nНельзя выбрать TTL по удобству таблицы. Он должен учитывать максимальный retry budget клиента, задержку proxy и время, после которого API запрещает поздний повтор. Слишком короткий TTL превращает поздний retry в новый эффект. Слишком длинный TTL удерживает результат и чувствительные данные без необходимости.
\nВсе адреса, SQL, ключи, result ID и ответы ниже учебные. Реальный endpoint, база, proxy, нагрузка и платёжная интеграция здесь не запускались. Перед внедрением нужно проверить конкурентные транзакции, размер response body, правила хранения данных и поведение каждого внешнего поставщика.
\nМеханизм готов, когда два конкурентных запроса с одним scope и ключом создают один effect; повтор после искусственно потерянного ответа возвращает сохранённый result ID; другой payload с тем же ключом получает conflict до обработчика; зависший внешний вызов имеет отдельный статусный маршрут; а TTL не открывает неоговорённый поздний retry. Эти условия должны быть проверены интеграционным тестом в выбранном стеке и видны в безопасных логах.
\n