Интеграции и API

Как не создать двойной заказ при повторном webhook или сетевом timeout

Есть класс ошибок, который почти невозможно заметить во время обычной ручной проверки веб-продукта. Пользователь открывает корзину. Нажимает: «Оформить заказ» Backend создаёт заказ. Всё работает. QA проходит. Проект выходит в production.

А спустя несколько недель поддержка получает странное обращение:

Я оформил один заказ, а в личном кабинете появилось два.

Или хуже:

Деньги списались один раз, но система выдала две лицензии.

Или:

После оплаты нам дважды отправили товар.

Причина может оказаться очень простой.

Первый запрос успешно выполнился, но ответ клиенту потерялся из-за сетевого timeout.

Frontend решил:

Запрос не прошёл.

и повторил его.

Для пользователя это один клик.

Для сервера — два HTTP-запроса.

Если архитектура считает каждый POST новой бизнес-операцией, появляются два заказа.

Похожая проблема возникает с webhook.

Платёжный сервис сообщает:

payment.succeeded

Backend обрабатывает событие, но по какой-то причине не успевает корректно подтвердить его получение.

Провайдер повторяет доставку.

Если обработчик написан как:

пришёл 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/orders

Backend снова честно выполняет команду.

Появляется:

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 B

Backend видит две разные операции.

И совершенно справедливо создаёт два заказа.

Правильно:

Пользователь нажал
«Оформить заказ»
        ↓
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.succeeded

Backend принимает его.

Переводит заказ:

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 conflict

Backend понимает:

Мы это событие уже видели.

Повторный 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, но и событие для последующей обработки.

BEGIN
Payment → SUCCEEDED
Order → PAID
Outbox → ORDER_PAID
COMMIT

Теперь база гарантирует:

если Order PAID
→ существует durable событие ORDER_PAID

Worker позднее читает 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 = done

Queue повторяет задачу.

Если 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 проектируют не только для сценария:

запрос
→ успешный ответ

Но и для гораздо более важного:

запрос
→ операция выполнена
→ ответ потерян
→ запрос повторён
→ результат всё равно остался один.

Вот это и есть настоящий тест идемпотентности.

Есть похожая задача?

Опишите продукт, интеграции и ограничения. До разработки зафиксируем объём, риски и критерии приёмки.

Обсудить проект →