Из-за временных ограничений на территории РФ наблюдаются проблемы с оплатой. Если платёж не проходит, оставьте запрос в службу поддержки.Служба поддержки работает 24/7 — мы всегда на связи по вопросам хостинга и серверов.Открыт прием заявок на аренду выделенных серверов и размещение оборудования в дата-центре.Напоминаем: рекомендуем включить резервное копирование для дополнительной защиты данных.Доступна новая линейка VPS/VDS с NVMe-дисками и увеличенной производительностью.Технические работы на части серверов завершены. Все сервисы работают в штатном режиме.
Статья10 мин чтенияПросмотры4

Повторный webhook не должен создать второй заказ: проверяем идемпотентность

Практический разбор получателя webhook: устойчивый event_id, UNIQUE-ограничение, транзакция, повтор после сбоя и две одновременные доставки. Учебный пример выполнен и проверен на Python 3.12 и SQLite 3.53.

Комментарии 0

Один упакованный заказ и несколько одинаковых доставочных конвертов на рабочем столе
В этой статье

Webhook может прийти повторно: отправитель не получил ответ, соединение оборвалось или две доставки стартовали почти одновременно. Если обработчик сначала проверяет «заказа ещё нет», а потом создаёт его, оба процесса могут увидеть пустое место и выполнить действие дважды.

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

Ниже — воспроизводимая учебная модель. Я проверил её на Python 3.12.14 и SQLite 3.53.1. Это не тест модуля 1С-Битрикс и не готовый фрагмент для боевого магазина: модель доказывает инвариант на локальной базе и отдельно показывает границу, за которой нужна идемпотентность внешнего API.

Сначала определим, что считается повтором

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

Что пришло

Решение получателя

HTTP-результат по контракту

Бизнес-действие

Новый event_id

Зарегистрировать и обработать

Успех после надёжной фиксации

Выполнить один раз

Тот же event_id и тот же хеш

Вернуть сохранённый результат

Успех или иной документированный ответ

Не повторять

Тот же event_id, другой хеш

Зафиксировать конфликт

Ответ по контракту сервиса

Не перезаписывать молча

Событие ещё обрабатывается

Не запускать вторую копию

Повторить позже или ответить сохранённым статусом

Не выполнять параллельно

Конкретный HTTP-код задаёт договор с отправителем. Универсальный код здесь опаснее, чем полезнее: один сервис повторяет доставку после любого ответа, кроме 200, другой различает 409 и 5xx, третий требует свой формат тела.

Минимальная схема: inbox и локальное действие

В учебной базе две таблицы. inbox_events хранит внешний event_id, хеш тела и состояние. local_orders изображает локальный бизнес-эффект — созданный заказ. Оба ограничения UNIQUE нужны не для красоты схемы: именно база разрешает гонку между двумя обработчиками.

CREATE TABLE inbox_events ( event_id TEXT PRIMARY KEY, payload_sha256 TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('processing', 'done')) ); CREATE TABLE local_orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT NOT NULL UNIQUE, external_order_id TEXT NOT NULL );

В документации SQLite ограничения PRIMARY KEY и UNIQUE задают уникальность на уровне таблицы. Предварительный SELECT без такого ограничения остаётся только наблюдением: между чтением и INSERT может успеть второй процесс.

Обработчик: сначала занять ключ, затем менять данные

Обработчик вычисляет SHA-256 исходного тела, начинает пишущую транзакцию и пытается вставить event_id. Если ключ уже занят, он читает сохранённый хеш: совпадение означает повтор, различие — конфликт. Новый заказ и отметка done фиксируются одним commit.

def handle_event(db, event_id, payload, fail_before_commit=False): payload_hash = hashlib.sha256(payload.encode()).hexdigest() order_id = str(json.loads(payload)["order_id"]) db.execute("BEGIN IMMEDIATE") try: db.execute( "INSERT INTO inbox_events(event_id, payload_sha256, status) " "VALUES (?, ?, 'processing')", (event_id, payload_hash), ) except sqlite3.IntegrityError: db.rollback() stored_hash, status = db.execute( "SELECT payload_sha256, status FROM inbox_events WHERE event_id = ?", (event_id,), ).fetchone() if stored_hash != payload_hash: return "conflict" return "duplicate" if status == "done" else "in_progress" try: db.execute( "INSERT INTO local_orders(event_id, external_order_id) VALUES (?, ?)", (event_id, order_id), ) if fail_before_commit: raise RuntimeError("failure before commit") db.execute( "UPDATE inbox_events SET status = 'done' WHERE event_id = ?", (event_id,), ) db.commit() return "processed" except Exception: db.rollback() raise

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

SQLite допускает только одну одновременную пишущую транзакцию; режим BEGIN IMMEDIATE запрашивает её сразу. Это свойство учебной среды. При переносе на MySQL или PostgreSQL нужно заново проверить изоляцию, тип таблиц и обработку конфликта уникальности.

Пять проверок, а не один удачный запрос

Скрипт запускался дважды в новой временной базе. Оба прогона дали одинаковый вывод. Для параллельного случая два потока открывали отдельные соединения и одновременно доставляли evt-44.

python3 webhook_idempotency_lab.py

first processed (1, 1) repeat duplicate (1, 1) changed-payload conflict (1, 1) failure failed_rolled_back (1, 1) retry processed (2, 2) concurrent ['duplicate', 'processed'] (3, 3) orders [('evt-42', '1001'), ('evt-43', '1002'), ('evt-44', '1003')]

Проверка

Ожидаемый результат

Наблюдение

Первый evt-42

Одно событие, один заказ

processed; (1, 1)

Точный повтор evt-42

Счётчики не меняются

duplicate; (1, 1)

evt-42 с другим order_id

Конфликт без перезаписи

conflict; (1, 1)

Сбой evt-43 до commit

Откат обеих вставок

failed_rolled_back; (1, 1)

Повтор evt-43

Одна успешная фиксация

processed; (2, 2)

Два параллельных evt-44

Один processed, один duplicate

Итог (3, 3)

Пара чисел в скобках — количество строк в inbox_events и local_orders. После последовательного и параллельного повтора они растут только для нового event_id. Тут сухой вывод полезнее красивого сообщения «всё работает»: проверяется именно число необратимых локальных результатов.

Два одинаковых события evt-42 проходят через уникальный event_id и создают один локальный заказ
Учебная модель: PRIMARY KEY по event_id превращает повтор в отдельный результат duplicate, а не во второй заказ.

Почему проверка «сначала SELECT» не защищает от гонки

Представим два обработчика. Оба выполняют SELECT и не находят evt-44. Оба переходят к INSERT. Если уникального ограничения нет, база честно сохранит две строки. Блокировка в приложении тоже может не помочь, когда обработчики работают в разных процессах или на разных узлах.

UNIQUE переносит решение в общий для всех обработчиков слой. Один INSERT выигрывает, второй получает конфликт и проверяет сохранённый результат. Одного UNIQUE недостаточно: транзакция всё равно нужна, иначе запись inbox может сохраниться, а заказ — нет, или наоборот.

Сбой до commit: повтор должен завершить работу

Учебный флаг fail_before_commit бросает исключение после вставки заказа, но до commit. Rollback удаляет и строку inbox, и локальный заказ. Следующая доставка evt-43 видит свободный ключ и выполняет операцию один раз.

Модуль sqlite3 в Python предоставляет явные commit и rollback. В тесте версия Python зафиксирована, потому что режим управления транзакциями у драйвера меняется между версиями и его нельзя оставлять неявным в инструкции.

Граница модели: внешний API не откатится вместе с базой

Теперь существенный крайний случай. Если внутри транзакции вызвать CRM, 1С или платёжный сервис, внешний запрос может успеть создать объект, а локальный commit затем упадёт. Rollback вернёт только локальную базу. При повторе обработчик снова вызовет внешний сервис — и может получить дубль.

Граница транзакции между локальными записями и удалённым API
Локальная транзакция откатывает inbox и заказ, но не уже выполненный вызов удалённого API. Внешнему действию нужна отдельная защита.
  • Если внешний API поддерживает ключ идемпотентности, передавайте устойчивый ключ той же логической операции и проверяйте его контракт.

  • Если операция асинхронная, сохраняйте локальное событие и запись outbox в одной транзакции, а отдельный исполнитель отправляет её наружу и фиксирует подтверждение.

  • Каждое последующее действие — письмо, отгрузка, документ в 1С — требует собственного устойчивого ключа или явного состояния. Защита создания заказа не делает идемпотентной всю цепочку.

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

Как перенести принцип в проект на 1С-Битрикс

SQLite в этой статье — измерительный стенд, а не рекомендация для боевой установки 1С-Битрикс. Перед внедрением нужно зафиксировать версию платформы, фактическую СУБД, место входящего обработчика, формат идентификатора события и список вызываемых обработчиков.

  1. Сначала проверить подпись и обязательные поля webhook. Неподтверждённое сообщение не должно занимать доверенный event_id.

  2. Выбрать ключ бизнес-события из контракта отправителя. Если его нет, составной ключ нужно документировать и проверять на коллизии; время получения не подходит.

  3. Добавить уникальный индекс в основную СУБД и проверить его создание на копии проекта с тем же типом таблиц и кодировкой.

  4. В одной локальной транзакции зарегистрировать событие, изменить состояние заказа и записать результат обработки.

  5. Отделить внешние вызовы через outbox или использовать документированный ключ идемпотентности внешнего API.

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

Правило подписи зависит от сервиса. В отдельном разборе показано, почему для проверки подписи webhook нужны исходные байты тела; нормализованный JSON может дать другой хеш.

Не смешивайте входящую дедупликацию с политикой исходящих повторов. В статье о бюджете повторных запросов API ограничивается число и срок попыток отправителя. Здесь получатель защищает один бизнес-эффект от повторной доставки.

Протокол приёмки

До запуска на реальном магазине подготовьте обезличенное событие и счётчики объектов до каждой фазы. Ожидаемый результат записывается заранее; иначе второй заказ легко назвать «особенностью теста» уже после появления.

  1. Отправить новое событие. Проверить одну запись inbox, одно локальное действие и сохранённый результат.

  2. Повторить те же байты с тем же event_id. Число заказов, писем, резервов и внешних документов не должно увеличиться.

  3. Отправить другое тело с тем же event_id. Обработчик должен остановить изменение и показать конфликт, не перезаписать прежние данные.

  4. Запустить две доставки одновременно. После завершения посчитать объекты, а не ограничиваться двумя успешными HTTP-ответами.

  5. Прервать обработку до локального commit. Убедиться в откате, затем повторить событие и получить ровно один результат.

  6. Отдельно оборвать связь после успешного внешнего действия, но до локального подтверждения. Проверить ключ идемпотентности или outbox на фактическом API.

Остановитесь, если тест затрагивает рабочий платёж, доставку, ERP или реальные персональные данные. Для приёмки нужен изолированный контур либо безопасная заглушка с управляемыми ответами.

Критерий готовности

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

Если такую схему нужно согласовать для 1С-Битрикс, 1С, CRM или другого API, сначала зафиксируйте контракт событий, границы транзакции и критерии приёмки. Это же набор входных данных для проектирования интеграции сайта.

Обсуждение 0

Делись опытом и задавай вопросы. Комментарии без ссылок появляются после проверки редактором.

Пока никто не написал. Начни обсуждение.