А спустя несколько недель поддержка получает странное обращение:
Я оформил один заказ, а в личном кабинете появилось два.
Или хуже:
Деньги списались один раз, но система выдала две лицензии.
Или:
После оплаты нам дважды отправили товар.
Причина может оказаться очень простой.
Первый запрос успешно выполнился, но ответ клиенту потерялся из-за сетевого timeout.
Frontend решил:
Запрос не прошёл.
и повторил его.
Для пользователя это один клик.
Для сервера — два HTTP-запроса.
Если архитектура считает каждый POST новой бизнес-операцией, появляются два заказа.
Похожая проблема возникает с webhook.
Платёжный сервис сообщает:
payment.succeededBackend обрабатывает событие, но по какой-то причине не успевает корректно подтвердить его получение.
Провайдер повторяет доставку.
Если обработчик написан как:
пришёл webhook
→ создать заказ
→ выдать доступ
→ отправить товародно событие способно выполнить одну и ту же бизнес-операцию повторно.
Эта проблема не относится только к платежам.
То же самое происходит при:
создании заказов;
оформлении подписки;
бронировании;
начислении бонусов;
выдаче лицензии;
создании заявки;
отправке документа;
резервировании товара;
создании проекта через внешний API.Поэтому одна из важнейших идей при проектировании API звучит так:
повторный технический запрос не обязательно означает новую бизнес-операцию.
Разберём, как сделать это свойство частью архитектуры, а не надеждой на то, что сеть никогда не даст сбой.
Сначала воспроизведём проблему
Представим интернет-магазин.
Frontend отправляет:
POST /api/ordersс корзиной:
{
"items": [
{
"productId": "p-184",
"quantity": 1
}
]
}Backend получает запрос.
Создаёт:
Order #5814фиксирует его в PostgreSQL и возвращает:
201 CreatedНо ответ теряется между сервером и браузером.
С точки зрения backend всё прошло успешно:
Order #5814
существуетС точки зрения frontend:
Network timeoutПользователь видит загрузку.
Через несколько секунд frontend повторяет:
POST /api/ordersBackend снова честно выполняет команду.
Появляется:
Order #5815Теперь в системе существуют:
Order #5814
Order #5815хотя бизнес-намерение было только одно:
Купить один товар.
Timeout не означает, что операция не выполнилась
Это фундаментальный момент.
После сетевого timeout клиент знает только одно:
Я не получил достоверный ответ.
Но из этого нельзя сделать вывод:
Сервер ничего не сделал.
В действительности возможны как минимум три сценария.
Запрос вообще не дошёл
Client
X
ServerОперация не выполнена.
Запрос дошёл, но сервер упал до фиксации
Request
↓
Server
↓
Error before COMMITОперация тоже не выполнена.
Запрос выполнился, ответ потерялся
Request
↓
Server
↓
COMMIT
↓
Response
X
ClientОперация выполнена.
Именно третий сценарий делает автоматический retry опасным.
Клиент не способен определить его только по факту сетевой ошибки.
Поэтому retry нужно проектировать заранее
Простая логика:
try {
await createOrder();
} catch {
await createOrder();
}для операции создания потенциально опасна.
Она означает:
Если мы не получили ответ, создадим ещё один объект.
Правильнее сформулировать другое намерение:
Если ответ потерялся, повторим ту же самую операцию, а не создадим новую.
Для этого появляется idempotency key.
Что такое idempotency key
Перед началом бизнес-операции создаётся уникальный идентификатор.
Например:
checkout:
7bf10e0c-...Frontend отправляет его вместе с запросом:
POST /api/orders
Idempotency-Key: 7bf10e0c-...Backend видит:
7bf10e0cв первый раз и выполняет операцию.
Создаёт:
Order #5814и сохраняет связь:
7bf10e0c
↓
Order #5814Ответ теряется.
Frontend повторяет запрос:
POST /api/orders
Idempotency-Key: 7bf10e0c-...Backend проверяет ключ и обнаруживает:
Эта операция уже выполнялась.
Поэтому не создаёт:
Order #5815а возвращает результат первой операции:
Order #5814Получается:
1 бизнес-намерение
+
N технических повторов
=
1 заказКлюч должен идентифицировать бизнес-намерение, а не сетевой запрос
Это одно из самых важных правил.
Неправильно:
Попытка HTTP #1
→ key A
timeout
Попытка HTTP #2
→ key BBackend видит две разные операции.
И совершенно справедливо создаёт два заказа.
Правильно:
Пользователь нажал
«Оформить заказ»
↓
Operation ID = A
↓
Request #1 → A
↓
timeout
↓
Request #2 → AНовый ключ нужен только тогда, когда пользователь действительно начинает новую операцию.
Например:
Я хочу оформить ещё один такой же заказ.
Тогда:
Operation ID = BГде генерировать ключ
Есть несколько вариантов.
Для пользовательской операции его удобно создать на frontend непосредственно перед началом checkout.
Например:
const operationId = crypto.randomUUID();После этого все retries конкретной операции используют этот же ID.
Но генерация ключа на клиенте не означает доверие клиенту.
Backend всё равно контролирует:
пользователя;
стоимость;
товары;
права;
состояние заказа.Ключ отвечает только на вопрос:
Мы уже выполняли это бизнес-намерение?
Ключ должен иметь область действия
Просто:
UUIDобычно недостаточно рассматривать изолированно.
Полезно связать его с контекстом:
userId
+
operationType
+
idempotencyKeyНапример:
482
CREATE_ORDER
7bf10e0cТогда одинаковый случайно переданный ключ другого пользователя не связывает две независимые операции.
В базе можно иметь:
idempotency_operations
user_id
operation
key
status
resource_id
request_hash
response_code
created_atи уникальность:
UNIQUE (
user_id,
operation,
key
)Одного ключа всё ещё недостаточно
Представим первый запрос:
{
"items": [
{
"productId": "A",
"quantity": 1
}
]
}с ключом:
KEY-123Он успешно создаёт заказ.
Потом клиент случайно отправляет:
{
"items": [
{
"productId": "B",
"quantity": 3
}
]
}с тем же:
KEY-123Что делать?
Нельзя просто вернуть первый заказ молча.
Потому что ключ тот же, а намерение уже другое.
Полезно сохранять fingerprint запроса
Из бизнес-значимых параметров можно построить нормализованный fingerprint.
Например:
user = 482
operation = CREATE_ORDER
items = A:1
currency = RUBпосле нормализации:
SHA-256
→ a92f...Сохраняем:
KEY-123
requestHash = a92f...При retry вычисляем hash снова.
Ключ тот же, hash тот же
Это повтор.
Возвращаем первоначальный результат.
Ключ тот же, hash другой
Это конфликт использования ключа.
Можно ответить:
409 Conflictс кодом:
IDEMPOTENCY_KEY_REUSEDТак система не маскирует ошибку клиента.
Что именно включать в fingerprint
Только данные, определяющие смысл операции.
Не стоит включать:
текущее время;
request ID транспортного уровня;
случайный trace ID.Иначе каждый retry будет выглядеть новым.
Для заказа могут быть важны:
товары;
количество;
адрес;
способ доставки;
валюта;
промокод.Точный набор зависит от продукта.
Сумму всё равно должен рассчитывать backend
Даже если request hash содержит:
amountнельзя позволять frontend определять итоговую стоимость.
Например, клиент отправляет:
{
"productId": "A",
"quantity": 1,
"price": 1
}Backend должен получить текущие или зафиксированные коммерческие условия самостоятельно.
Idempotency защищает от повторов.
Она не заменяет обычную серверную валидацию.
Как хранить результат первой операции
Представим первая операция завершилась:
201 Createdи вернула:
{
"orderId": "5814",
"status": "AWAITING_PAYMENT"
}При повторе полезно вернуть логически тот же результат.
Можно хранить:
resourceId = 5814а ответ собрать заново.
Или для некоторых API сохранить часть response.
Главное, чтобы retry не выглядел для клиента новой операцией.
Важный вопрос: когда записывать idempotency key
Плохая последовательность:
1. Создать заказ
2. Потом сохранить idempotency keyПредставим процесс падает между первым и вторым шагом.
Заказ уже существует.
Ключ ещё не сохранён.
Retry создаёт второй заказ.
Поэтому запись idempotency и сама бизнес-операция должны быть согласованы транзакционно.
В простом случае это можно сделать одной транзакцией PostgreSQL
Концептуально:
BEGINзатем:
INSERT idempotency operationпосле:
INSERT orderзатем связать:
idempotency.resource_id = order.idи:
COMMITЕсли транзакция падает:
ни ключа,
ни заказаЕсли проходит:
и ключ,
и заказсуществуют вместе.
Но здесь появляется конкуренция
Представим frontend дважды отправил один запрос почти одновременно.
Request A
Request BОба приходят с:
KEY-123Оба пытаются проверить:
SELECT *
FROM idempotency_operations
WHERE key = 'KEY-123';Оба получают:
ничегопотому что запись ещё не создана.
Оба решают:
Можно создавать заказ.
Это классическая race condition.
«Сначала SELECT, потом INSERT» недостаточно
Проверка:
если не существует
→ создатьбез database constraint небезопасна при параллельности.
Нужно, чтобы сама БД умела гарантировать уникальность.
Например:
UNIQUE(user_id, operation, key)Тогда два процесса одновременно пытаются выполнить:
INSERTОдин выигрывает.
Второй получает конфликт уникальности.
База становится последней линией защиты.
Это важный архитектурный принцип
Если бизнес говорит:
Такая запись должна существовать только одна,
полезно по возможности выразить правило не только:
if (...)но и структурным ограничением базы.
Application code может ошибиться.
Два процесса могут выполняться одновременно.
Database constraint видит итоговое состояние атомарно.
Что делать второму запросу, пока первый ещё работает
Интересный случай.
Request A получил ключ:
KEY-123и начал обработку.
Запись имеет:
status = PROCESSINGВ этот момент приходит Request B с тем же ключом.
У нас пока нет готового Order ID.
Есть несколько стратегий.
Например, вернуть:
409 Conflictили:
202 Acceptedи сообщить:
OPERATION_IN_PROGRESSПосле небольшой задержки клиент может проверить результат.
Другой вариант — дождаться завершения первой операции в разумных пределах.
Главное — не начинать её второй раз.
Почему PROCESSING полезен
Idempotency record может иметь жизненный цикл:
PROCESSING
↓
COMPLETEDили:
PROCESSING
↓
FAILEDТеперь backend понимает различие между:
Мы никогда этого не видели.
и:
Кто-то уже выполняет эту операцию прямо сейчас.
Это особенно полезно при высоком concurrency.
Но PROCESSING создаёт проблему после падения процесса
Допустим:
status = PROCESSINGпосле чего Node.js процесс аварийно завершился.
Если запись останется в таком состоянии навсегда, клиент больше никогда не сможет повторить операцию.
Значит, нужна политика восстановления.
Например:
processing_started_atи lease/timeout.
Но здесь нужно действовать осторожно.
Нельзя просто сказать:
Прошло пять минут — запускаем ещё раз.
Если внешняя операция могла успеть выполниться, мы возвращаемся к исходной проблеме неопределённого результата.
Локальная транзакция и внешний API — разные миры
До сих пор мы создавали заказ только в своей PostgreSQL.
Это удобно: одна транзакция.
Теперь представим, что внутри операции нужно вызвать внешнюю систему:
Создать заказ
↓
Зарезервировать доставку
↓
Создать платёжМы не можем сделать обычный:
BEGINв PostgreSQL и включить внешний HTTP API в ту же ACID-транзакцию.
Внешний сервис не откатится автоматически вместе с нашей базой.
Не держите database transaction открытой вокруг долгого HTTP-запроса без необходимости
Например:
BEGIN
↓
LOCK order
↓
HTTP request к внешнему API
↓
ждём 15 секунд
↓
COMMITможет создавать:
долгие locks;
рост contention;
непредсказуемое время транзакции.Чаще полезнее разделять процесс на локальные durable states и внешние шаги.
Для внешних API нужен второй слой идемпотентности
Допустим наш backend гарантировал:
один Order #5814Но затем дважды вызвал:
POST /external-delivery/orderПолучим две доставки.
Поэтому при обращении к внешнему сервису нужно использовать его механизм идемпотентности, если он предусмотрен.
Например, ключом может стать:
delivery:create:order:5814Теперь retry внешней операции означает:
Создай доставку для Order 5814, если ещё не создавал.
а не:
Создай ещё одну доставку.
Нельзя полагаться только на idempotency провайдера
Это распространённая ошибка.
Если платёжный сервис поддерживает:
Idempotency-Keyэто защищает от дублей внутри его API.
Он не знает, что происходит в вашей базе.
Например:
ваш backend дважды создал Orderа для каждого использовал свой idempotency key.
Провайдер совершенно корректно создаст два платежа.
Поэтому требуется два уровня:
Ваш API
→ idempotency бизнес-операции
External API
→ idempotency внешней операцииТеперь перейдём к webhook
Пусть платёж:
payment-839успешно завершён.
Провайдер отправляет:
POST /webhooks/paymentс событием:
payment.succeededBackend принимает его.
Переводит заказ:
AWAITING_PAYMENT
↓
PAIDЗатем выдаёт клиенту цифровую лицензию.
Что если webhook придёт второй раз
Это нужно считать нормальным сценарием.
Например, ваш сервер обработал событие, но не успел отправить успешный HTTP-ответ.
Или ответ потерялся.
Отправитель не знает:
Вы получили событие?
Поэтому может доставить его повторно.
Это не баг webhook.
Это нормальное свойство надёжной доставки событий.
Webhook handler должен иметь inbox
Для входящих событий удобно использовать паттерн, который иногда называют Inbox.
Создаём таблицу:
incoming_events
provider
event_id
event_type
payload_hash
received_at
processed_at
statusИ уникальность:
UNIQUE(provider, event_id)Первое событие:
INSERT
→ successПовтор:
INSERT
→ unique conflictBackend понимает:
Мы это событие уже видели.
Повторный webhook не должен считаться ошибкой
Это важный нюанс.
Не обязательно возвращать:
500потому что:
Такое event уже существует.
Напротив, если оно ранее успешно обработано, повтор обычно является нормальной ситуацией.
Мы можем подтвердить:
Событие принято.
И не выполнять business effect второй раз.
Но event ID — только первый уровень защиты
Представим провайдер прислал два разных события:
event-1
event-2и оба в итоге подтверждают:
payment-839 = SUCCEEDEDЕсли бизнес-обработчик выполняет:
createOrder()для каждого события, dedupe по event ID не спасает.
Поэтому сама бизнес-операция тоже должна быть идемпотентной.
Нужно дедуплицировать намерение, а не только сообщение
Это очень важная мысль.
Transport говорит:
event-1Бизнес говорит:
Payment 839
успешно оплатил
Order 5814Именно второй факт должен применяться один раз.
Поэтому state machine может разрешать:
AWAITING_PAYMENT → PAIDно повторное:
PAID → PAIDне должно повторно запускать выдачу товара.
State machine становится дополнительной защитой
Пример:
if (
order.status === 'AWAITING_PAYMENT' &&
payment.status === 'SUCCEEDED'
) {
transitionOrderToPaid();
}При повторе:
order.status = PAIDПереход уже выполнен.
Система не выполняет его второй раз.
Но этого всё равно мало, если побочные эффекты находятся вне той же модели.
Самая неприятная ошибка возникает после COMMIT
Представим:
1. Order → PAID
2. COMMIT
3. createLicense()После пункта 2 процесс падает.
Получается:
Order:
PAID
License:
не созданаWebhook приходит повторно.
Обработчик видит:
Order уже PAIDи ничего не делает.
Лицензия потеряна.
То есть простое:
Если статус уже такой — игнорировать повтор.
тоже недостаточно.
Здесь нужен Outbox
При переходе заказа в оплаченный статус в одной транзакции создаётся не только новый state, но и событие для последующей обработки.
BEGINPayment → SUCCEEDED
Order → PAID
Outbox → ORDER_PAIDCOMMITТеперь база гарантирует:
если Order PAID
→ существует durable событие ORDER_PAIDWorker позднее читает outbox и выполняет:
создание лицензии;
уведомление;
отправку заказа;
начисление бонусов.Если worker упадёт, событие останется.
Но Outbox тоже работает как at-least-once
Worker может:
1. создать лицензию
2. упасть
3. не отметить событие выполненнымПосле рестарта он снова прочитает:
ORDER_PAIDПоэтому обработчик:
createLicense()тоже должен быть идемпотентным.
Например, лицензия создаётся с business key:
order_id = 5814и ограничением:
UNIQUE(order_id)Retry получает:
Лицензия для этого заказа уже существует.
Вторая не создаётся.
Получается цепочка защит
Для одной покупки:
Пользователь
↓
POST /orders
↓
API idempotency
↓
Order
↓
Payment API
↓
Provider idempotency
↓
Webhook
↓
Inbox deduplication
↓
State transition
↓
Outbox
↓
Worker
↓
Business-effect idempotencyКаждый слой решает свою проблему.
Нет одной волшебной функции:
makeEverythingExactlyOnce()Почему «exactly once» лучше не обещать
На уровне бизнеса мы действительно хотим:
один заказ;
одну лицензию;
одно начисление.Но транспортные механизмы проще проектировать исходя из:
сообщение может прийти
один или несколько раз.Затем сделать обработку безопасной при повторе.
То есть вместо требования:
Событие должно быть доставлено ровно один раз.
получаем более практичное:
Событие можно доставить повторно, но итоговый бизнес-результат останется один.
Двойной заказ может появиться даже без платежей
Возьмём обычную CRM.
Frontend:
Создать заявкуPOST успешно создаёт:
Lead #914Ответ потерялся.
Браузер повторяет POST.
Появляется:
Lead #915В итоге менеджеры звонят одному клиенту дважды.
То же решение:
idempotency keyполезно и здесь.
Особенно опасны операции с внешними последствиями
Например:
Отправить SMS
Начислить бонус
Создать доставку
Выдать промокод
Создать лицензию
Списать деньгиПовтор нельзя просто «исправить потом» без последствий.
Поэтому именно такие команды стоит рассматривать в первую очередь при проектировании идемпотентности.
Не каждый POST обязательно требует idempotency key
Если endpoint:
POST /searchтолько выполняет поиск и не меняет состояние, риск другой.
Или:
POST /previewгенерирует временный preview.
Идемпотентность особенно нужна там, где запрос создаёт устойчивый бизнес-эффект.
Например:
создать заказ;
оплатить;
забронировать;
выдать;
начислить;
отправить.Хорошее API делает поведение retry явным
В документации endpoint полезно прямо указать:
POST /orders
Idempotency-Key:
обязателени определить:
срок хранения ключа;
scope;
поведение повторного запроса;
поведение ключа с другим payload.Тогда frontend, мобильное приложение и внешние интеграторы понимают контракт.
Срок хранения idempotency record зависит от бизнеса
Нельзя автоматически сказать:
Храним ровно час.
Если мобильное приложение может повторить queued operation через сутки, час слишком мало.
Если идентификаторы нужны для финансового расследования, срок может быть ещё больше.
Нужно смотреть на:
максимальное окно retry;
характер операции;
стоимость storage;
требования аудита.Но записи нельзя хранить бесконечно без причины
При миллионах запросов таблица idempotency тоже будет расти.
Значит, для неё нужна retention policy.
Например:
старые завершённые операции
→ удаляются через N днейесли бизнес и интеграционный контракт позволяют это.
Получается уже знакомый production-принцип:
технические данные тоже должны иметь жизненный цикл.
Что делать с FAILED
Не все ошибки одинаковы.
Представим запрос:
CREATE_ORDER
KEY-123получает бизнес-ошибку:
OUT_OF_STOCKПовтор с тем же payload через секунду, возможно, должен вернуть тот же результат.
Но если склад пополнился через час?
Нужна продуктовая политика.
Один вариант:
детерминированные окончательные ошибки
→ кешировать как результат операции.Другой:
временные ошибки
→ разрешать безопасный повтор.Важно определить это сознательно.
Не стоит превращать idempotency в бесконечный response cache
Её цель:
Не выполнить одну mutation дважды.
А не:
Кешировать любой запрос продукта.
Если смешать эти задачи, слой становится сложнее поддерживать.
Webhook должен проверяться до deduplication бизнес-данных
Событие пришло извне.
Сначала нужно установить, что оно действительно относится к доверенному провайдеру предусмотренным для конкретной интеграции способом.
И только потом применять его к бизнес-состоянию.
Иначе злоумышленник сможет прислать:
payment.succeededдля чужого заказа.
Но повторное событие всё равно полезно безопасно распознавать как можно раньше
После базовой проверки webhook можно попытаться записать в inbox.
Если:
event уже COMPLETEDтяжёлую бизнес-обработку повторять не нужно.
Это уменьшает нагрузку.
Быстрое подтверждение webhook — тоже часть надёжности
Представим обработчик:
получить webhook
↓
создать PDF
↓
отправить email
↓
обновить CRM
↓
обновить аналитику
↓
вернуть HTTP 200Любая медленная внешняя система увеличивает вероятность timeout.
Отправитель начинает retry.
Мы сами создаём поток повторных событий.
Гораздо надёжнее:
получить
↓
проверить
↓
сохранить
↓
зафиксировать важное состояние
↓
enqueue side effects
↓
ответитьА всё тяжёлое выполнить асинхронно.
Реальный webhook действительно нужно считать повторяемым
Это не только теоретическое допущение. Например, в текущей документации ЮKassa указано, что получатель должен подтвердить webhook HTTP 200; если этого не произошло, сервис продолжает повторять доставку уведомления. Тот же API использует ключ идемпотентности для безопасного повторения mutating-запросов при проблемах сети.
То есть архитектура:
webhook придёт один разизначально строится на неверном предположении.
Unknown result — отдельное состояние, а не обязательно FAILURE
Есть ещё одна важная идея.
Backend отправляет запрос внешнему API.
Получает:
HTTP 500или timeout.
Очень хочется сразу поставить:
FAILEDНо иногда правильное состояние:
UNKNOWNили:
PENDING_CONFIRMATIONПотому что мы реально не знаем, выполнилась ли внешняя операция.
Неизвестный результат нужно разрешать через reconciliation
Например:
PaymentOperation
status = UNKNOWN
providerReference = ...Background worker делает:
GET provider operationи выясняет:
SUCCEEDEDили:
FAILEDТолько после этого переводит локальное состояние в финальное.
Если API поддерживает повтор той же команды с тем же idempotency key, можно безопасно использовать этот механизм согласно его контракту.
Например, актуальная документация ЮKassa отдельно рекомендует при неопределённом результате вроде pending или некоторых серверных ошибок повторять операцию с теми же данными и тем же ключом идемпотентности либо получать актуальное состояние объекта отдельным запросом.
Это лучше, чем превращать timeout в «попробуем ещё раз как новую операцию»
Правило можно сформулировать так:
Неизвестен результат
≠
операция не выполнена.Сначала нужно восстановить знание о предыдущей попытке.
Только потом принимать решение о новой.
А что если пользователь действительно хочет повторить заказ
Допустим первый заказ:
Order #5814создан успешно.
Через минуту пользователь нажимает:
Купить ещё раз.
Теперь это уже новое бизнес-намерение.
Должен появиться:
новый idempotency keyи:
Order #5815То есть задача idempotency не в том, чтобы:
Никогда не создавать два одинаковых заказа.
Она должна различать:
случайный технический повтори:
намеренное повторное действие пользователя.Поэтому ключ связан именно с операцией, а не с содержимым корзины навсегда.
Дедупликация только по одинаковому содержимому опасна
Можно попытаться написать:
Если у пользователя уже есть точно такой заказ за последние пять минут — второй не создаём.
Это плохая универсальная стратегия.
Пользователь вполне может намеренно купить:
один и тот же товар
два раза подряд.Payload одинаковый.
Намерения разные.
Поэтому:
payload equalityне заменяет:
operation identityА вот fingerprint полезен внутри одной operation identity
То есть:
Idempotency-Key
→ определяет операциюRequest hash
→ проверяет,
что retry действительно повторяет
то же самое содержимоеУ этих двух механизмов разные задачи.
Что делать, если backend сам повторяет работу
Не только клиент способен отправить повтор.
Background worker может получить job:
CREATE_SHIPMENT order=5814начать выполнение, успешно создать доставку и упасть до:
job.status = doneQueue повторяет задачу.
Если worker неидемпотентен, создаётся вторая доставка.
Поэтому idempotency нужна и внутри фоновой архитектуры.
Job ID не всегда является business key
Повторная очередь может создать:
job #100
job #101для одного намерения:
CREATE_SHIPMENT order=5814Дедупликация только по job ID не поможет.
Полезен business key:
shipment:order:5814или уникальность:
shipments.order_idЧем ближе защита к бизнес-инварианту, тем она сильнее
Допустим правило:
Один заказ может иметь одну активную цифровую лицензию.
Тогда сильная защита:
UNIQUE(order_id)в таблице лицензий.
Даже если:
frontend ошибся;
backend retry ошибся;
worker запустился дважды;
webhook пришёл дважды,вторая лицензия физически не появится.
Это defense in depth.
Но unique constraint не заменяет нормальную обработку
Если второй INSERT просто упадёт:
500мы получим шум и retries.
Application layer должен понимать:
Это не авария. Желаемый результат уже существует.
И вернуть/использовать существующую сущность.
Database constraint — страховка.
Не полноценный UX.
Idempotent create иногда превращается в get-or-create
Концептуально:
найди по business key
↓
есть?
↙ ↘
да нет
↓ ↓
вернуть создатьНо снова помним о race condition.
Финальная гарантия должна выполняться атомарно, например через unique constraint и корректную обработку конфликта.
Как тестировать такую систему
Обычный тест:
POST /orders
→ 201не доказывает почти ничего относительно дублей.
Нужны специальные сценарии.
Тест №1. Один ключ отправляется дважды
Request A:
KEY-123
Request B:
KEY-123Ожидаем:
1 orderи одинаковую ссылку на business resource.
Тест №2. Два параллельных запроса
Не последовательно:
A
затем Bа реально одновременно:
A ──────┐
├→ backend
B ──────┘Ожидаем:
1 orderИменно этот тест проверяет database race.
Тест №3. Ответ потерян после COMMIT
Нужно симулировать:
Order created
COMMIT
response droppedпосле чего клиент повторяет тот же request.
Ожидаем:
тот же Order IDТест №4. Один ключ, другой payload
KEY-123 + product Aзатем:
KEY-123 + product BОжидаем:
409 IDEMPOTENCY_KEY_REUSEDили другой заранее определённый conflict.
Но не молчаливое создание B.
Тест №5. Webhook приходит дважды
event-7
event-7Ожидаем:
1 state transition
1 business effectТест №6. Два разных event сообщают тот же финальный факт
event-7
payment 839 succeeded
event-8
payment 839 succeededОжидаем:
Order → PAID один разТест №7. Worker падает после side effect
Например:
license created
↓
worker crashпосле повторной job:
вторая license
не появляетсяТест №8. Timeout внешнего API после успешной операции
Нужно проверить, что retry:
использует прежний idempotency keyили reconciliation выясняет состояние предыдущей операции.
Не создаётся новый внешний объект.
Тест №9. События приходят не по порядку
Например:
SUCCEEDEDуже обработан.
После него приходит старый:
PENDINGСостояние не должно откатиться.
Тест №10. Клиент намеренно повторяет покупку
Первый checkout:
KEY-Aвторой сознательный:
KEY-BТеперь должны появиться:
2 заказаЭтот тест не менее важен.
Слишком агрессивная дедупликация тоже является ошибкой.
Что логировать
При расследовании особенно полезна цепочка корреляции.
Например:
traceId
operationId
idempotencyKey
orderId
paymentId
providerEventId
jobIdТогда из обращения:
Заказ задублировался.
можно восстановить весь путь.
Но секреты, платёжные реквизиты и другие чувствительные данные без необходимости в такие логи попадать не должны.
Метрики позволяют заметить проблему раньше пользователя
Например:
idempotency retries / minuteпоказывает, насколько часто сеть или frontend повторяют mutations.
duplicate webhook countпоказывает реальную частоту повторной доставки.
idempotency conflictsможет указывать на ошибку клиентского приложения, повторно использующего ключ для другого payload.
operations stuck PROCESSINGпоказывает зависшие бизнес-операции.
Полезно различать transport retry и business retry
Transport retry:
Я не знаю, дошёл ли предыдущий запрос, поэтому повторяю ту же операцию.
Используется:
тот же idempotency key.Business retry:
Предыдущая попытка точно закончилась неуспешно, пользователь хочет попробовать заново.
Используется:
новый idempotency key.Это очень полезное различие для API-контракта.
Например, неуспешная оплата
Первая попытка:
Payment A
→ CANCELEDЭто подтверждённый финальный результат.
Пользователь выбирает другую карту.
Теперь это новая попытка:
Payment Bс новым key.
Но если первая попытка закончилась:
network timeout
результат неизвестенсоздавать B рано.
Сначала нужно выяснить судьбу A.
Почему UI тоже имеет значение
Frontend может уменьшить вероятность случайных повторов.
После нажатия:
[Оформить заказ]кнопку можно временно заблокировать:
[Оформляем…]Это хороший UX.
Но это не механизм гарантии.
Пользователь может:
перезагрузить страницу;
открыть вторую вкладку;
использовать API;
получить автоматический retry сети.Поэтому защита всегда должна существовать на backend.
Disabled button и idempotency решают разные задачи
Frontend:
не дать человеку случайно
кликнуть дваждыBackend:
не позволить системе
создать два результата,
даже если два запроса всё же пришлиНужны оба.
Но второй является настоящим инвариантом.
Не стоит делать один глобальный idempotency middleware для всего без модели операций
Можно написать middleware:
любой POST + key
→ кешируем responseЭто быстро.
Но затем появляются вопросы:
Как долго хранить?
Какие ошибки кешировать?
Какие поля входят в hash?
Какой scope ключа?
Что делать с PROCESSING?
Можно ли повторить FAILED?
Что считается той же операцией?Поэтому idempotency лучше проектировать как часть бизнес-контракта критичных commands, а не только как универсальный HTTP-хак.
Иногда operation table полезнее response cache
Например:
OrderCreationOperationявно содержит:
operationId
userId
cartSnapshot
status
orderId
createdAtТеперь идемпотентность становится частью domain model.
Особенно это удобно для сложных многошаговых процессов.
Для простого CRUD можно использовать более лёгкий механизм
Не каждый проект требует отдельной domain-таблицы для каждой команды.
Можно иметь общий:
idempotency_keysи стандартный middleware.
Главное — понимать его гарантии.
Архитектура должна соответствовать цене ошибки.
Цена ошибки определяет необходимую глубину
Если повторно создался черновик комментария — неприятно.
Если:
дважды списались деньги;
дважды отправлен товар;
дважды начислен баланс;цена значительно выше.
Чем выше стоимость duplicate effect, тем больше имеет смысл использовать несколько защитных уровней:
API key
+
unique constraint
+
state machine
+
inbox
+
outbox
+
idempotent worker
+
reconciliation.Как выглядит практичная архитектура целиком
USER
│
│ CREATE ORDER
│ KEY-A
▼
┌─────────┐
│ API │
└────┬────┘
│
Idempotency check
│
▼
┌──────────────┐
│ PostgreSQL │
│ │
│ Operation A │
│ Order 5814 │
└──────┬───────┘
│
│ external operation
▼
┌──────────┐
│ Provider │
└────┬─────┘
│
│ webhook
▼
┌──────────┐
│ Inbox │
└────┬─────┘
│
deduplication
│
▼
State Machine
│
▼
┌──────────────────┐
│ DB transaction │
│ │
│ Order → PAID │
│ Outbox event │
└────────┬─────────┘
│
▼
Worker
│
idempotent effect
│
▼
ONE BUSINESS RESULTНа схеме много элементов.
Но каждый появился не ради архитектурной красоты.
Каждый закрывает конкретное место, где повтор способен превратиться в дублирование.
Самая полезная мысль — считать повтор нормальным
Очень много проблем исчезает после изменения базового предположения.
Вместо:
Почему webhook пришёл два раза?
спрашиваем:
Почему обработчик не способен безопасно принять его два раза?
Вместо:
Почему frontend повторил POST?
спрашиваем:
Почему повтор одного намерения создаёт новый объект?
Вместо:
Почему worker запустил job повторно?
спрашиваем:
Почему повтор job создаёт второй бизнес-эффект?
Это совершенно другой уровень проектирования.
Где idempotency особенно нужна
Практически всегда стоит внимательно проверить операции:
Создать заказ
Создать платёж
Подтвердить оплату
Создать подписку
Выдать лицензию
Начислить бонусы
Создать доставку
Забронировать место
Отправить перевод
Создать счёт
Вернуть деньгиЕсли повтор каждой из них способен причинить реальный финансовый или операционный ущерб, архитектура должна иметь явный ответ на вопрос:
Что произойдёт при повторе?
Какой ответ считается хорошим
Не:
Скорее всего, frontend второй раз не отправит.
Не:
Webhook обычно приходит один раз.
Не:
Мы блокируем кнопку.
А:
Любой повтор той же operation identity даст тот же бизнес-результат, а database constraints и state machine не позволят создать второй эффект даже при параллельной обработке.
Вот это уже гарантия, на которой можно строить production.
Вместо вывода
Один из самых неприятных сценариев распределённого веб-приложения выглядит так:
Операция выполнилась
↓
Ответ потерялся
↓
Клиент считает,
что операция не выполнена
↓
Retry
↓
Вторая операцияWebhook добавляет зеркальную проблему:
Событие обработано
↓
Подтверждение потерялось
↓
Повторная доставка
↓
Второй business effectУстранить сетевые сбои невозможно.
Нельзя гарантировать, что packet никогда не потеряется, процесс никогда не упадёт, а отправитель никогда не повторит событие.
Но можно сделать так, чтобы эти повторы не меняли бизнес-смысл операции.
Для этого полезно мыслить несколькими уровнями:
Operation identity
↓
Idempotency key
↓
Request fingerprint
↓
Database uniqueness
↓
Webhook Inbox
↓
State machine
↓
Transactional Outbox
↓
Idempotent workerНе каждый проект потребует все эти механизмы.
Но принцип остаётся одинаковым:
если повтор одной операции способен создать финансовую или бизнес-проблему, защита от повтора должна существовать на сервере как архитектурное свойство, а не как надежда на аккуратное поведение браузера и внешнего API.
Именно поэтому хороший API проектируют не только для сценария:
запрос
→ успешный ответНо и для гораздо более важного:
запрос
→ операция выполнена
→ ответ потерян
→ запрос повторён
→ результат всё равно остался один.Вот это и есть настоящий тест идемпотентности.