Коротко

  • Повторная доставка — нормальная часть сетевого обмена, поэтому обработчик должен безопасно принимать одно событие несколько раз.
  • Идемпотентность строится на стабильном ключе операции, уникальном ограничении в базе и сохранённом результате, а не на проверке «такой записи пока нет».
  • Вебхук нужно быстро проверить и записать во входящий журнал, а тяжёлую бизнес-обработку выполнять отдельно через очередь.
  • Защита от дублей не заменяет контроль потерь: необходимы сверка с источником, повторная доставка, журнал состояний и понятная работа с ошибками.

Почему дубли — не исключение, а штатный сценарий

Пользователь нажал «Отправить», но браузер не дождался ответа и повторил запрос. Платёжный сервис передал вебхук, не получил подтверждение вовремя и отправил его ещё раз. Worker завершил операцию во внешней системе, но перезапустился до фиксации результата. Во всех трёх случаях сеть и приложения ведут себя ожидаемо: они пытаются довести действие до конца. Ошибка возникает, когда бизнес-операция спроектирована так, будто каждое сообщение приходит ровно один раз.

RFC 9110 называет HTTP-метод идемпотентным, если несколько одинаковых запросов имеют тот же задуманный эффект, что и один запрос. GET и PUT обладают такой семантикой по стандарту, а POST — нет автоматически. Но название метода не защищает бизнес от повторов: endpoint создания заявки на POST можно сделать идемпотентным прикладным ключом, а неудачно реализованный PUT способен породить лишние побочные действия.

Начните с единого идентификатора операции

До выбора очереди и базы договоритесь, что именно считается одной операцией. Для запроса клиента это может быть сгенерированный на клиенте idempotency key. Для входящего вебхука — идентификатор доставки провайдера либо составной ключ из источника, типа события и идентификатора объекта. Для импорта — номер строки вместе с версией файла. Ключ должен оставаться тем же при повторной попытке той же операции и меняться, когда пользователь действительно создаёт новую.

  • Храните ключ вместе с владельцем или контуром интеграции: одинаковые значения от разных клиентов не должны конфликтовать.
  • Сохраняйте отпечаток значимых параметров. Повтор того же ключа с другим содержимым лучше отклонить, чем молча применить новые данные.
  • Определите срок хранения ключа по бизнес-риску и максимальному окну повторной доставки, а не по удобству очистки таблицы.
  • Передавайте correlation ID через API, очередь и журнал, чтобы одна операция находилась целиком, а не по фрагментам логов.

Одинаковый ключ означает повтор той же операции, а не просто похожий запрос.

Правило проектирования Agentix Labs

Как сделать изменяющий API идемпотентным

  1. 01

    Примите ключ и проверьте запрос

    Клиент отправляет idempotency key отдельно от бизнес-полей. Сервер валидирует авторизацию и формат данных, затем вычисляет отпечаток параметров.

  2. 02

    Зафиксируйте право на выполнение

    В транзакции создайте запись операции с уникальным индексом по владельцу и ключу. Уникальное ограничение в базе закрывает гонку между двумя одновременно пришедшими запросами.

  3. 03

    Выполните бизнес-изменение

    Создайте заявку, измените заказ или поставьте команду в очередь в той же транзакционной границе, где это возможно. Статус операции должен явно различать выполнение, успех и контролируемую ошибку.

  4. 04

    Сохраните стабильный ответ

    Запишите код и полезную часть ответа. Повторный запрос с тем же ключом и параметрами получает сохранённый результат, а не запускает действие заново.

Stripe документирует похожую модель: клиент задаёт уникальный ключ, а сервер связывает с ним результат первого выполнения и возвращает его при повторе. Для собственной системы не нужно копировать чужие сроки и форматы, но принцип полезен: ключ, параметры и результат образуют одну проверяемую запись.

Безопасный приём вебхука: проверить, записать, подтвердить

Endpoint вебхука находится на границе доверия. Он должен проверить HTTPS-соединение на уровне инфраструктуры, подпись по исходному телу запроса, допустимую временную метку, тип события и источник. Секрет нельзя передавать в URL или писать в открытый журнал. После проверки полезно сохранить исходный payload, служебные заголовки и идентификатор доставки во входящий журнал — inbox.

Тяжёлую работу лучше не выполнять внутри HTTP-запроса. Приёмник фиксирует событие и возвращает успешный ответ, а отдельный worker применяет бизнес-логику. Так временно медленная CRM или генерация документа не заставляет провайдера считать доставку неуспешной. GitHub в рекомендациях по вебхукам предлагает проверять тип и действие события, использовать секрет и уникальный идентификатор доставки; при ручной повторной доставке идентификатор GitHub сохраняется, что позволяет распознать повтор.

СлойЧто хранитКак защищает
Webhook endpointПодпись, заголовки, payloadОтбрасывает неподлинные и некорректные запросы
InboxИсточник, delivery ID, тип, состояниеНе принимает одну доставку как новую дважды
WorkerПопытки и результат обработкиБезопасно повторяет временно неудавшуюся работу
Бизнес-таблицаВнешний объект и его версияНе создаёт второй бизнес-эффект

Транзакции и outbox закрывают разрыв между базой и очередью

Даже хороший inbox не решает обратную проблему: приложение записало заказ в базу, но упало до отправки команды в CRM. Или отправило команду, а затем откатило локальную транзакцию. Если база и брокер сообщений не поддерживают общую транзакцию, прямой вызов между ними оставляет окно потери или дубля.

Паттерн transactional outbox помещает бизнес-изменение и запись о будущем событии в одну транзакцию базы. Отдельный publisher забирает неопубликованные записи и отправляет их в очередь. Он тоже может отправить сообщение повторно, поэтому получатель применяет свой inbox и идемпотентную бизнес-логику. В результате сбой сдвигает обработку во времени, но не делает состояние невосстановимым.

  • Не помечайте outbox-запись отправленной раньше фактической передачи.
  • Не удаляйте событие сразу после первой попытки: храните состояние, количество попыток и последнюю ошибку.
  • Если порядок важен, определите ключ партиционирования по бизнес-объекту и проверяйте версию события.
  • Любой внешний вызов отделяйте от долгой транзакции базы и проектируйте повторяемым.

Повторы должны быть ограниченными и осмысленными

Повтор полезен только для временной ошибки: сетевого разрыва, тайм-аута, ограничения частоты или недоступности зависимости. Ошибка схемы, неверная подпись, отсутствующий обязательный идентификатор или запрещённый переход состояния не исправятся от десятой попытки. Такие события нужно переводить в понятное состояние для разбора, а не бесконечно возвращать в очередь.

  1. 01

    Классифицируйте ошибку

    Разделите временные, постоянные и неоднозначные ошибки. Для неоднозначного результата сначала запросите состояние внешней операции, если провайдер это позволяет.

  2. 02

    Добавьте задержку и случайное смещение

    Экспоненциальная задержка с jitter уменьшает одновременные повторные обращения после восстановления сервиса.

  3. 03

    Ограничьте попытки

    После установленного порога переводите событие в failed или dead-letter очередь, сохраняя причину и возможность безопасного перезапуска.

  4. 04

    Сделайте ручной повтор тем же событием

    Операторская кнопка должна сохранять исходный delivery ID или idempotency key, иначе устранение ошибки само создаст дубль.

Как обнаруживать не только дубли, но и потери

Дедупликация отвечает на вопрос «обрабатывали ли мы это событие». Она не доказывает, что все события вообще пришли. Поэтому критичные интеграции дополняют вебхуки периодической сверкой: запрашивают у источника объекты, изменившиеся после последнего подтверждённого курсора, и сравнивают их с локальным журналом. Курсор обновляется только после успешной обработки диапазона, иначе часть изменений можно пропустить.

  • Количество принятых, уникальных, повторных, успешных и ошибочных событий по каждому источнику.
  • Возраст самого старого необработанного события и размер очереди.
  • Доля временных ошибок и переходов в ручной разбор без публикации выдуманных нормативов.
  • Поиск по correlation ID от входящего запроса до бизнес-объекта и исходящего действия.
  • Сигнал о расхождении между источником и локальной системой по результатам сверки.

Журнал должен объяснять состояние человеку: что принято, почему пропущено как дубль, какая попытка выполняется, какое внешнее действие уже подтверждено и что разрешено повторить. Маскирование токенов, персональных данных и платёжных реквизитов обязательно закладывается до включения подробного логирования.

Как эта схема выглядит в реальном продукте

В QRush событийный слой получает обновления P2C-потока, а правила обработки учитывают идентификатор заявки, состояние аккаунта, лимиты и защиту от повторной обработки. Изолированные воркеры и общее оперативное состояние помогают не смешивать торговые сессии. Этот кейс показывает, почему дедупликация — часть модели операции, а не фильтр строк перед внешним вызовом.

В TransferBot заявка проходит через Telegram-диалог, оркестратор, PostgreSQL и браузерный worker. Здесь важно сохранить состояние длинного сценария, не запустить второй активный перевод и корректно обработать ожидание, отмену, ошибку или истечение времени. Оба проекта подтверждают общий подход: центральная запись операции, явные статусы, изолированное исполнение и контролируемый повтор.

СитуацияМинимальная защитаДополнительный контроль
Повтор формы или API-запросаIdempotency key и уникальный индексСохранённый ответ и сверка параметров
Повторный вебхукDelivery ID в inboxВерсия объекта и подпись payload
Сбой после записи в базуTransactional outboxМониторинг неопубликованных записей
Неизвестный результат внешнего вызоваПовтор с тем же ключомЗапрос статуса и ручной разбор
Пропущенная доставкаСверка по курсоруПовторная доставка из журнала источника

Чек-лист перед запуском интеграции

  • Для каждой изменяющей операции определены бизнес-идентификатор, владелец ключа и срок хранения.
  • Одновременные одинаковые запросы сталкиваются с уникальным ограничением, а не проходят через раздельные SELECT и INSERT.
  • Повтор ключа с изменёнными параметрами отклоняется и попадает в диагностику.
  • Webhook проверяет подпись по исходному телу, тип события и delivery ID, затем быстро записывает событие.
  • Бизнес-обработка вынесена в worker и допускает безопасный повтор после перезапуска.
  • Изменение данных и постановка исходящего события связаны через транзакцию и outbox.
  • Ошибки классифицированы; повторы ограничены, а ручной перезапуск сохраняет исходный ключ.
  • Есть периодическая сверка с источником, поиск по correlation ID и понятный статус для оператора.
  • Тесты воспроизводят двойной запрос, параллельный запрос, тайм-аут после внешнего успеха, повтор вебхука и пропуск доставки.

Начинать внедрение удобно с карты одного критичного процесса: например, путь заявки от формы до CRM или путь статуса оплаты до выдачи результата. На схеме отмечаются владельцы данных, границы транзакций, возможные повторы, точки неизвестного результата и способ сверки. После этого становится понятно, где достаточно уникального индекса, где нужен inbox или outbox, а где требуется изменение самого бизнес-процесса.

Частые вопросы

Чем idempotency key отличается от request ID?

Request ID обычно обозначает конкретную попытку и помогает найти её в логах. Idempotency key обозначает бизнес-операцию и остаётся прежним при её повторной попытке. Полезно хранить оба: первый для диагностики транспорта, второй для защиты бизнес-эффекта.

Можно ли удалять дубли только по хешу payload?

Хеш помогает сравнить содержимое, но редко является достаточным бизнес-ключом. Два законных события могут иметь одинаковые поля, а один объект может присылаться с технически отличающимся payload. Лучше использовать стабильный идентификатор источника и версию события, а хеш оставить дополнительной проверкой.

Нужно ли всегда отвечать вебхуку кодом 200?

Успешный ответ следует возвращать после проверки и надёжной фиксации события. Неподлинный или некорректный запрос нельзя подтверждать как принятый. Поведение конкретных кодов и повторов нужно сверять с документацией провайдера.

Защитит ли очередь сообщений от дублей?

Очередь развязывает приём и обработку, но сообщение может быть доставлено повторно после сбоя consumer. Защита появляется только вместе с идентификатором события, inbox, уникальным ограничением и идемпотентным обработчиком.

Что делать, если внешний API не поддерживает idempotency key?

Использовать стабильный внешний идентификатор, запрос состояния до повтора, локальный журнал исходящих команд и операторский разбор неоднозначных результатов. Если API допускает поиск созданного объекта по вашему reference, его следует задавать при первом вызове.

С чего начать аудит уже работающей интеграции?

Выберите один критичный сценарий и воспроизведите повтор запроса, параллельную обработку, тайм-аут после возможного успеха и повтор вебхука. Затем проверьте, можно ли по одному correlation ID восстановить весь путь и безопасно перезапустить незавершённую операцию.

Источники

  1. RFC 9110: HTTP Semantics — Idempotent MethodsПроверено 30 июля 2026 г.
  2. Stripe API Reference — Idempotent requestsПроверено 30 июля 2026 г.
  3. GitHub Docs — Best practices for using webhooksПроверено 30 июля 2026 г.
  4. GitHub Docs — Redelivering webhooksПроверено 30 июля 2026 г.
  5. PostgreSQL Documentation — INSERT and ON CONFLICTПроверено 30 июля 2026 г.
Материал подготовлен редакцией Agentix Labs с использованием AI для исследования, структуры и черновика. Финальный текст, факты и рекомендации проверяет Владислав.