Коротко
- Повторная доставка — нормальная часть сетевого обмена, поэтому обработчик должен безопасно принимать одно событие несколько раз.
- Идемпотентность строится на стабильном ключе операции, уникальном ограничении в базе и сохранённом результате, а не на проверке «такой записи пока нет».
- Вебхук нужно быстро проверить и записать во входящий журнал, а тяжёлую бизнес-обработку выполнять отдельно через очередь.
- Защита от дублей не заменяет контроль потерь: необходимы сверка с источником, повторная доставка, журнал состояний и понятная работа с ошибками.
Почему дубли — не исключение, а штатный сценарий
Пользователь нажал «Отправить», но браузер не дождался ответа и повторил запрос. Платёжный сервис передал вебхук, не получил подтверждение вовремя и отправил его ещё раз. Worker завершил операцию во внешней системе, но перезапустился до фиксации результата. Во всех трёх случаях сеть и приложения ведут себя ожидаемо: они пытаются довести действие до конца. Ошибка возникает, когда бизнес-операция спроектирована так, будто каждое сообщение приходит ровно один раз.
RFC 9110 называет HTTP-метод идемпотентным, если несколько одинаковых запросов имеют тот же задуманный эффект, что и один запрос. GET и PUT обладают такой семантикой по стандарту, а POST — нет автоматически. Но название метода не защищает бизнес от повторов: endpoint создания заявки на POST можно сделать идемпотентным прикладным ключом, а неудачно реализованный PUT способен породить лишние побочные действия.
Начните с единого идентификатора операции
До выбора очереди и базы договоритесь, что именно считается одной операцией. Для запроса клиента это может быть сгенерированный на клиенте idempotency key. Для входящего вебхука — идентификатор доставки провайдера либо составной ключ из источника, типа события и идентификатора объекта. Для импорта — номер строки вместе с версией файла. Ключ должен оставаться тем же при повторной попытке той же операции и меняться, когда пользователь действительно создаёт новую.
- Храните ключ вместе с владельцем или контуром интеграции: одинаковые значения от разных клиентов не должны конфликтовать.
- Сохраняйте отпечаток значимых параметров. Повтор того же ключа с другим содержимым лучше отклонить, чем молча применить новые данные.
- Определите срок хранения ключа по бизнес-риску и максимальному окну повторной доставки, а не по удобству очистки таблицы.
- Передавайте correlation ID через API, очередь и журнал, чтобы одна операция находилась целиком, а не по фрагментам логов.
Одинаковый ключ означает повтор той же операции, а не просто похожий запрос.
Правило проектирования Agentix Labs
Как сделать изменяющий API идемпотентным
-
01
Примите ключ и проверьте запрос
Клиент отправляет idempotency key отдельно от бизнес-полей. Сервер валидирует авторизацию и формат данных, затем вычисляет отпечаток параметров.
-
02
Зафиксируйте право на выполнение
В транзакции создайте запись операции с уникальным индексом по владельцу и ключу. Уникальное ограничение в базе закрывает гонку между двумя одновременно пришедшими запросами.
-
03
Выполните бизнес-изменение
Создайте заявку, измените заказ или поставьте команду в очередь в той же транзакционной границе, где это возможно. Статус операции должен явно различать выполнение, успех и контролируемую ошибку.
-
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-запись отправленной раньше фактической передачи.
- Не удаляйте событие сразу после первой попытки: храните состояние, количество попыток и последнюю ошибку.
- Если порядок важен, определите ключ партиционирования по бизнес-объекту и проверяйте версию события.
- Любой внешний вызов отделяйте от долгой транзакции базы и проектируйте повторяемым.
Повторы должны быть ограниченными и осмысленными
Повтор полезен только для временной ошибки: сетевого разрыва, тайм-аута, ограничения частоты или недоступности зависимости. Ошибка схемы, неверная подпись, отсутствующий обязательный идентификатор или запрещённый переход состояния не исправятся от десятой попытки. Такие события нужно переводить в понятное состояние для разбора, а не бесконечно возвращать в очередь.
-
01
Классифицируйте ошибку
Разделите временные, постоянные и неоднозначные ошибки. Для неоднозначного результата сначала запросите состояние внешней операции, если провайдер это позволяет.
-
02
Добавьте задержку и случайное смещение
Экспоненциальная задержка с jitter уменьшает одновременные повторные обращения после восстановления сервиса.
-
03
Ограничьте попытки
После установленного порога переводите событие в failed или dead-letter очередь, сохраняя причину и возможность безопасного перезапуска.
-
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 восстановить весь путь и безопасно перезапустить незавершённую операцию.
Источники
- RFC 9110: HTTP Semantics — Idempotent MethodsПроверено 30 июля 2026 г.
- Stripe API Reference — Idempotent requestsПроверено 30 июля 2026 г.
- GitHub Docs — Best practices for using webhooksПроверено 30 июля 2026 г.
- GitHub Docs — Redelivering webhooksПроверено 30 июля 2026 г.
- PostgreSQL Documentation — INSERT and ON CONFLICTПроверено 30 июля 2026 г.