Что такое идемпотентность? Почему она необходима платежным API и API для бухгалтерского учета
Как повторный запрос создает дубликат счета, что гарантирует ключ идемпотентности, с какими сбоями он должен справляться и как реализовать его корректно.
Операция является идемпотентной, если ее многократное выполнение дает тот же результат, что и однократное. Чтение записи по своей природе идемпотентно. Создание счета — нет: вызовите его дважды, и у вас появится два счета, две дебиторские задолженности и клиент, который интересуется, почему с него взяли оплату дважды.
Этот пробел важен, поскольку сети падают по конкретному и неприятному сценарию: запрос проходит успешно, а ответ теряется. Клиент видит тайм-аут (timeout) и не может отличить ситуацию „запрос никогда не доходил“ от ситуации „дошел, отработал, ответ потерялся“. Единственным безопасным вариантом для него выглядит повторная отправка запроса, и именно этот повтор создает дубликат.
Почему в бухгалтерии все еще хуже
В большинстве систем дубликат — это просто досадная помеха. В реестре проводок (ledger) это нарастающая проблема:
- Дубликат счета расходует номер из непрерывной последовательности, поэтому его удаление не вариант: неизменяемость означает, что для исправления требуется кредитная нота (credit note), а это документ, который видит ваш клиент.
- Дубликат формирует проводки в журнале (journal entries), поэтому выручка, НДС и дебиторская задолженность оказываются завышенными, пока это кто-то не заметит.
- Он попадает в декларацию по НДС и в регистры i.SAF (в Литве).
- Если он инициировал платеж, деньги реально списались.
Подход „просто проверять на дубликаты“ тоже не работает, так как два действительно независимых счета одному и тому же клиенту на ту же сумму в тот же день — это абсолютно нормально. Система не может уловить намерение по содержимому — клиент должен заявить его явно.
Как работают ключи идемпотентности
Клиент генерирует уникальный ключ для каждой логической операции и передает его вместе с запросом:
POST /v1/sales/invoices/create
Idempotency-Key: 8f14e45f-ea1b-4c2b-9f3a-2d7c1e0b5a91
Idempotency-Key: 8f14e45f-ea1b-4c2b-9f3a-2d7c1e0b5a91
Затем сервер гарантирует следующее:
- Первый запрос с этим ключом — обрабатывается в обычном порядке, результат сохраняется в привязке к ключу.
- Повторный запрос с тем же ключом и тем же телом (payload) — повторное выполнение не производится, возвращается сохраненный ответ.
- Тот же ключ, но другое тело запроса — отклонение запроса. Ключ подтверждает, что это та же операция; иное тело означает, что это не так, а молчаливый возврат старого результата хуже, чем ошибка.
- Повторный запрос, пока исходный еще выполняется — отклонение с ошибкой, допускающей повтор (retryable error), вместо параллельного выполнения.
Пункт 4 — это то, что упускают наивные реализации. Клиент, у которого по истечении 5 секунд сработал тайм-аут и который отправляет повторный запрос на 6-й секунде, столкнется с собственным обрабатываемым запросом (in-flight request); без защиты от параллельного выполнения оба запроса пройдут, и вы получите дубликат, для предотвращения которого и задумывался этот механизм.
Корректная генерация ключей
Ключ должен идентифицировать операцию, а не попытку:
- Правильно: UUID, сгенерированный один раз при нажатии пользователем кнопки „выставить счет“ и используемый повторно при каждой попытке отправить этот клик.
- Правильно: детерминированный ключ, полученный из вашей собственной доменной области —
order-1041-invoice. - Неправильно: новый UUID для каждой HTTP-попытки. Каждый повтор получает новый ключ, поэтому каждый повтор является новой операцией, и вы ничего не реализовали.
- Неправильно: хэш тела запроса (payload). В этом случае две совершенно одинаковые операции вызовут конфликт, и вторая будет молча проглочена.
Ключам необходим период хранения (retention period) — достаточно долгий, чтобы покрыть реальные окна повторов и сбои на стороне клиента, но не бесконечный.
Чего это не решает
Идемпотентность защищает от повторного выполнения одного и того же запроса. Она не защищает от:
- Создания одинаковых счетов двумя разными пользователями из разных сеансов — это проблема бизнес-логики, которая решается ограничениями уникальности (например, один счет на один заказ).
- Логических дубликатов с разными ключами.
- Недетерминизма внутри операции: если обработчик генерирует метку времени (timestamp) или номер, сохраненный ответ фиксирует первый вариант. Это желаемое поведение, но его стоит понимать.
Этот механизм дополняет ограничения базы данных, а не заменяет их. Реестр проводок Nordlet использует оба подхода: ключи идемпотентности останавливают повторные запросы, в то время как уникальные индексы предотвращают появление дубликатов расчетных пакетов по идентификатору выплаты (payout ID), дубликатов входящих счетов по поставщику и номеру документа, а также дубликатов расчетов амортизации по активу и периоду.
Как это реализовано в Nordlet
Передавайте заголовок Idempotency-Key в любом запросе, изменяющем состояние, — это непустая строка длиной не более 255 символов.
Первый запрос с заданным ключом выполняется, и его результат сохраняется. Повторный запрос с тем же ключом и тем же телом возвращает сохраненный ответ без повторного выполнения, поэтому счет создается ровно один раз, сколько бы раз ни повторялся вызов.
Два сценария сбоев явно обрабатываются, а не замалчиваются:
- Тот же ключ, другое тело запроса → отклоняется: "Idempotency-Key was already used with a different request payload." Это отсекает действительно опасные ситуации, когда клиент повторно использует ключ, но меняет сумму.
- Тот же ключ, оригинал все еще в обработке → отклоняется как операция, которую можно повторить: "A request with this Idempotency-Key is still being processed, retry shortly." Исключено параллельное двойное выполнение.
Ключи имеют область видимости в пределах компании (scoped per company), поэтому два арендатора (tenant), генерирующие одинаковые UUID, никогда не конфликтуют.
В обратном направлении работает аналогичная гарантия. Доставка веб-хуков (webhook) гарантирует доставку хотя бы один раз (at-least-once), что означает: ваша конечная точка (endpoint) время от времени будет получать одно и то же событие дважды — после тайм-аута, когда доставка на самом деле прошла успешно, или при повторной доставке. Каждое событие содержит ID, и ваш обработчик должен фиксировать обработанные ID и игнорировать повторы. Идемпотентность — это свойство, необходимое обеим сторонам интеграции; в руководстве по соглашениям API сторона запросов рассмотрена подробно.
FAQ
Что такое ключ идемпотентности?
Это созданный клиентом идентификатор, который передается с запросом и позволяет серверу распознать повтор той же логической операции. Первый запрос выполняется; повторные запросы с тем же ключом возвращают сохраненный результат вместо повторного выполнения.
Почему бухгалтерским API нужна идемпотентность?
Потому что дублирующаяся запись создает реальный документ с реальными последствиями: второй счет занимает порядковый номер, отражается в реестре проводок и попадает в декларацию по НДС. В отличие от дублирующегося чтения, его нельзя просто удалить — его необходимо исправить кредитной нотой.
Стоит ли генерировать новый ключ для каждого повторного запроса?
Нет, это полностью сводит механизм на нет. Генерируйте один ключ на логическую операцию и используйте его повторно для каждого нового вызова при повторе этой операции. Новый ключ для каждой попытки означает, что каждая попытка воспринимается как новая операция.
Что произойдет, если использовать ключ повторно, но с другим телом запроса?
Запрос будет отклонен. Ключ подтверждает, что это та же операция, поэтому измененное тело означает наличие проблемы — скорее всего, ошибки с повторным использованием ключа на стороне клиента. Молчаливый возврат предыдущего результата был бы хуже.
Предотвращает ли идемпотентность появление всех дубликатов?
Нет. Она предотвращает повторное выполнение одного и того же запроса. Два разных пользователя, независимо друг от друга создающие одинаковый счет, сформировали два разных запроса. Это задача для ограничений уникальности в рамках бизнес-логики, а не то, что может обнаружить ключ идемпотентности.