Idempotency и ретраи в webhooks: как сделать синхронизацию…
  • Интеграции
  • Автор: Команда CrmAI
  • Опубликовано:
Idempotency и webhooks: устойчивая интеграция CRM без дублей

Четверг, пять вечера. Менеджер по продажам звонит в IT-отдел, и в его голосе слышна паника: «Ребят, тут какая-то магия. В CRM висит три одинаковых заказа от Ивановой. Она же покупала один раз, я с ней лично разговаривал!». И начинается детективное расследование. Поднимаем логи, смотрим историю — ага, вот оно. Ночью интернет-провайдер немного «икнул», связь на 20 секунд пропала. Webhook от интернет-магазина отправился, не дождался ответа, попробовал ещё раз... и ещё. А наша система, не моргнув глазом, каждый раз создавала новый заказ. Три заказа в CRM, три задания на склад, и — о ужас — три SMS клиенту с текстом «Спасибо за покупку».

Узнаёте себя? Если вы хоть раз настраивали интеграцию между системами, то наверняка сталкивались с чем-то подобным. И это ещё цветочки. Бывали случаи пострашнее: тройное списание денег со счёта клиента (представляете звонок в банк?), отправка трёх посылок на один адрес, или три письма с напоминанием о записи к врачу. Клиенты в шоке, бухгалтерия не спит ночами, а репутация компании — ну, вы поняли.

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

«Мы полгода жили с этой проблемой — дубли в 1С, дубли в CRM, ручная чистка каждую неделю. Когда наконец внедрили нормальную идемпотентность, сэкономили минимум 10 часов работы бухгалтера в месяц. И нервов — не сосчитать.»

Технический директор
Интернет-магазин электроники, Астана
Цитата

Идемпотентность: слово страшное, идея простая

Когда впервые слышишь слово «идемпотентность», кажется, что это какое-то заклинание из Гарри Поттера. Особенно если ты не математик и не программист-теоретик. Но на самом деле за этим монстром скрывается элементарная идея: операция идемпотентна, если её можно повторить сколько угодно раз, и результат будет один и тот же.

Вот вам жизненный пример. Заходите в лифт, жмёте кнопку с цифрой «5». Лифт поехал на пятый этаж. А теперь представьте, что вы нетерпеливый человек (или просто любите щёлкать кнопками) и давите на пятёрку ещё пять раз подряд. Что произойдёт? Правильно — лифт всё равно приедет на пятый этаж, а не умножит ваши нажатия и не рванёт на двадцать пятый. Вот это и есть идемпотентность в действии.

А теперь контрпример. Вы на сайте интернет-магазина добавляете товар в корзину. Нажали кнопку «Добавить» один раз — появился один товар. Нажали три раза — бац, уже три штуки лежит. И это логично! Может, вам правда нужно три одинаковых футболки. Но когда речь идёт об автоматической синхронизации между системами, такое поведение превращается в кошмар.

Идемпотентные vs неидемпотентные операции

Идемпотентные (безопасно повторять)
  • Установить статус заказа = «Оплачен»
  • Обновить email клиента на «ivan@example.kz»
  • Удалить товар из корзины по ID
  • Получить информацию о заказе (GET-запрос)
Неидемпотентные (опасно повторять)
  • Создать новый заказ
  • Списать деньги со счёта
  • Отправить SMS/email
  • Добавить запись в историю действий

И вот что важно понять: большинство webhook'ов, которые прилетают в вашу CRM — это именно неидемпотентные операции. Создать новую сделку, добавить комментарий, зарегистрировать платёж. Выполни дважды — получишь дубль. Не предусмотрел защиту — рано или поздно обязательно огребёшь проблем.

Но есть и хорошая новость! Почти любую операцию можно превратить в идемпотентную. Магического рецепта нет, но базовый принцип простой: перед тем как что-то делать, спроси себя: «А я это уже делал?». Вот и вся философия.

Почему webhooks — это зона повышенного риска

Давайте разберёмся, что такое webhook по-человечески. Это когда одна система сама стучится к вам с новостями: «Эй, у меня тут заказ новый появился!», а не вы каждые пять секунд надоедливо спрашиваете: «Ну что, есть что-нибудь новенькое?». В теории звучит супер удобно. На практике — добро пожаловать в мир приключений и головной боли.

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

Сценарии сбоев при отправке webhook

1
Таймаут на стороне получателя

Ваш сервер получил запрос, начал обрабатывать, но ответ отправил через 35 секунд. Отправитель ждал 30 — решил, что запрос потерялся, отправил повторно.

2
Сетевой сбой «посередине»

Вы обработали запрос и отправили «200 OK», но ответ потерялся по дороге. Отправитель не получил подтверждение — отправил снова.

3
Временная недоступность

Ваш сервер был недоступен 5 минут (деплой, перезагрузка, DDoS). Отправитель накопил очередь и отправил всё разом — возможно, с повторами.

4
Баг в логике повторов отправителя

Система-отправитель ошибочно считает, что запрос не доставлен. Или кто-то нажал кнопку «Переотправить» вручную.

Обратите внимание на важную деталь: в трёх из четырёх сценариев вы-то всё сделали как надо! Запрос получили, обработали, данные сохранили в базу. Но вот незадача — отправитель об этом не узнал. И что ему остаётся делать? Правильно, слать повторно. Это не баг, это «фича» распределённых систем, с которой приходится жить.

И знаете, что ещё веселее? Многие популярные системы (Kaspi, Ozon, Wildberries, всякие платёжные шлюзы) настроены на очень агрессивные повторы. Типа такой логики: «Не получили ответ за 10 секунд? Давай ещё раз. Опять нет? Ещё! И ещё! И ещё!». Они будут долбиться до победного или пока не упрутся в лимит попыток.

Итог? Вместо одного аккуратного webhook'а к вам прилетает три, пять, а бывает и десять совершенно одинаковых запросов. И если ваша система к этому не готова — добро пожаловать в хаос, наслаждайтесь дублями во всех базах данных.

Три способа защитить систему от дублей

За годы работы мы перепробовали кучу подходов. Честно скажу: половина того, что красиво выглядит в документации — со схемами, диаграммами и умными терминами — на практике либо не работает, либо требует такой экспертизы, которой в обычной команде просто нет. Поэтому расскажу только про то, что реально внедряем у клиентов и что не подводит.

Способ первый: ключ идемпотентности

Самый надёжный вариант. Идея простая до неприличия: отправитель вместе с данными передаёт уникальный номер запроса — что-то вроде серийного номера посылки. Получатель этот номер сохраняет. Пришёл новый запрос — первым делом смотрим: «А не видел ли я уже этот номерок?»

Пример webhook с Idempotency Key
{
  "idempotency_key": "ord_2024_12345_v1",
  "event": "order.created",
  "data": {
    "order_id": "12345",
    "customer": "Иван Петров",
    "amount": 45000,
    "currency": "KZT"
  },
  "timestamp": "2025-01-05T14:30:00+06:00"
}

Когда приходит такой запрос, ваша система работает по простому алгоритму:

  1. Смотрит в базу: «Эй, а у меня уже есть запись с ключом ord_2024_12345_v1
  2. Если нет — отлично, обрабатывает запрос как новый, сохраняет ключ в базу, возвращает результат.
  3. Если да — ага, дубль! Ничего не делает, просто возвращает сохранённый результат от прошлой обработки.

Вся красота этого метода в том, что он работает даже когда запросы прилетают одновременно, параллельно. Главное — правильно использовать возможности базы данных: транзакции или атомарные операции вроде «вставить, только если такого ещё нет» (в PostgreSQL это делается через INSERT ... ON CONFLICT DO NOTHING).

Способ 2: Проверка по бизнес-идентификатору

Жизнь не всегда идеальна, и не все системы отправляют этот самый idempotency_key — он пока не стал общепринятым стандартом. Но знаете что? Почти все системы отправляют какой-нибудь бизнес-идентификатор: номер заказа, ID платежа, номер документа. Вот его-то мы и можем использовать!

Принцип такой: перед тем как создавать новую сущность, проверяем базу: «А нет ли у меня уже заказа с таким external_id?». Нашли? Значит, это повторная отправка, и создавать ничего не надо.

Алгоритм обработки webhook с проверкой по external_id

1. Получаем webhook
order_id = "12345"
2. Ищем в базе
SELECT * FROM orders WHERE external_id = '12345'
Не найдено
→ Создаём заказ
или
Найдено
→ Возвращаем существующий

Этот способ попроще в реализации, но есть важный нюанс: он отлично работает для операций создания, а вот для обновлений (скажем, «изменить статус заказа на "Доставлен"») нужен более тонкий подход — версионирование или проверка по timestamp.

Способ 3: Хэширование содержимого

Бывают совсем грустные случаи: ни idempotency_key нет, ни нормального бизнес-идентификатора не прилетает. Что делать? Можно пойти на хитрость — вычислить хэш от всего содержимого запроса и использовать его как ключ для проверки на дубли.

Схема такая: берём всё тело запроса целиком, прогоняем через SHA-256, получаем хэш-строку, сохраняем. Приходит новый запрос — считаем хэш, смотрим: «О, такой уже был!» — значит, дубль.

Но тут есть важное ограничение: этот метод сработает только если повторные запросы абсолютно идентичны, байт в байт. Стоит отправителю добавить в JSON новый timestamp или поменять порядок полей — и хэши станут разными. Защита не сработает. Так что этот способ — крайний случай, plan C, когда все остальные варианты недоступны.

Idempotency и webhooks: устойчивая интеграция CRM

Если вы отправляете webhooks: как делать ретраи правильно

До сих пор мы говорили о том, как защититься, если вы принимаете webhook'и. А теперь давайте развернёмся на 180 градусов. Если вы сами отправляете их (скажем, из вашей CRM в 1С, или из интернет-магазина в службу доставки) — то важно делать это культурно, чтобы не превратиться в источник головной боли для партнёров.

Правило 1: Экспоненциальный backoff

Не надо бомбардировать получателя повторами каждую секунду. Это прямой путь попасть в бан-лист. Лучше так: первая попытка не прошла — ок, подождём 5 секунд. Вторая тоже облом? Подождём уже 15 секунд. Третья провалилась — 45 секунд паузы. И так далее, постепенно увеличивая интервал между попытками.

Попытка Интервал ожидания Суммарное время Комментарий
1 Сразу 0 сек Первая попытка
2 5 сек 5 сек Возможно, кратковременный сбой
3 15 сек 20 сек Сервер перегружен?
4 45 сек ~1 мин Серьёзная проблема
5 2 мин ~3 мин Возможно, деплой у получателя
6-10 5-15 мин до 1 часа Крупный сбой, алерт администратору

Зачем такие сложности? А вот зачем: если у получателя проблемы со стороны сервера, ваши повторы каждую секунду только добивают его окончательно. А если это временный сбой (перезагрузка сервера, внезапный пик нагрузки), то экспоненциальный backoff даёт системе шанс отдышаться и восстановиться.

Правило 2: Добавляйте jitter (случайный разброс)

Представьте картину: у вас в очереди накопилось 1000 недоставленных webhook'ов. Все они терпеливо ждут свои 5 секунд — и бабах, отправляются одновременно, скопом. Сервер получателя, который только-только поднялся после падения, снова уходит в нокаут под такой лавиной. Классический сценарий.

Решение простое — добавляйте случайный разброс к интервалам. Не ровно 5 секунд, а случайное число от 3 до 7. Не ровно 15 секунд, а где-то от 10 до 20. Так запросы размазываются во времени, и вы не создаёте искусственные пики нагрузки.

Правило 3: Всегда отправляйте idempotency_key

Даже если вы на 100% уверены, что получатель его всё равно не использует — отправляйте. Почему? Во-первых, может начать использовать завтра. Во-вторых, это признак профессионализма и культуры разработки. А в-третьих, вам самим будет проще отлаживать проблемы: по ключу легко отследить всю историю попыток доставки конкретного события.

Антипаттерн: бесконечные ретраи

Встречал системы, которые настроены на повторы «до победного конца». Это очень плохая идея. Смотрите: если webhook не доставился за 10 попыток в течение суток — вероятность того, что проблема рассосётся сама, стремится к нулю. Что делать? Сохраните это событие в dead letter queue (очередь неразобранных), отправьте алерт администратору, и остановитесь. А то получится, что вы просто бесконечно стучитесь в закрытую дверь.

Практика: как мы реализуем это в проектах

Ладно, хватит теории. Покажу вам, как это всё выглядит в реальной жизни. Вот типовая архитектура, которую мы раскатываем почти на всех проектах, где фигурируют webhook'и.

Шаг 1: Входная точка (endpoint)

Все webhook'и сыплются на один endpoint. Что мы делаем первым делом? Проверяем подпись запроса (если отправитель её вообще добавляет), сохраняем весь сырой запрос целиком в таблицу incoming_webhooks. И сразу же отвечаем «202 Accepted» — мол, ребят, запрос принят, обработаем чуть позже.

Почему именно 202, а не привычный 200? Потому что 200 означает «сделано», а мы пока только приняли, но не обработали. Это честно по отношению к HTTP-семантике. Но главное — мы отвечаем супербыстро, меньше чем за 100 миллисекунд. Отправитель доволен и не падает в таймаут.

Шаг 2: Проверка на дубль

Ещё до сохранения смотрим: а нет ли у нас уже записи с таким же idempotency_key (или external_id, если ключа идемпотентности не прилетело)? Если нашли — всё, не создаём новую запись, просто возвращаем 202 и на этом всё. Дубль отсечён в самом начале пути.

Шаг 3: Асинхронная обработка

Дальше в дело вступает фоновый процесс (worker). Он забирает новые записи из таблицы incoming_webhooks и уже спокойно, не торопясь, их обрабатывает: создаёт сделки в CRM, обновляет статусы заказов, шлёт уведомления. Если что-то пошло не так и обработка упала с ошибкой — запись остаётся в очереди, чтобы попробовать ещё раз.

Шаг 4: Идемпотентность бизнес-операций

А что если дубль всё-таки как-то проскочил на первом шаге (ну мало ли, idempotency_key не было, баг где-то вылез)? Не беда! Бизнес-логика тоже защищена. Перед тем как создать заказ, проверяем: «А нет ли уже заказа с external_id = X?». Перед созданием контакта: «А не существует ли контакт с таким email или телефоном?».

Получается двойная защита — и на входе, и в бизнес-логике. С такой обороной дубли практически не имеют шансов.

Архитектура обработки webhook с защитой от дублей

Отправитель
Kaspi, 1C, сайт...
Входная точка
Валидация + дедупликация
incoming_webhooks
Worker
Бизнес-логика + повторы

Особенности интеграций в Казахстане

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

Kaspi и его webhook'и

Kaspi Магазин — это вообще мастодонт казахстанского e-commerce, куда ни плюнь — все там торгуют. Их API и webhook'и в целом работают стабильно, но есть нюанс. Webhook'и приходят с order_id, и его вполне можно использовать как ключ идемпотентности. Но ловушка вот в чём: когда статус заказа меняется, прилетает новый webhook с тем же самым order_id. И это не дубль! Это обновление. Надо чётко различать.

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

1С и проблема «долгих» операций

Интеграция с 1С — это вообще отдельная эпопея, достойная песен и сказаний. 1С обожает отвечать медленно. Особенно если база данных разрослась, или железо сервера так себе. Запрос спокойно может обрабатываться 30-60 секунд. А теперь представьте: ваш webhook-отправитель настроен на таймаут 10 секунд. Он не дождался ответа, решил «всё пропало», и отправил повторно. А 1С тем временем создаёт второй документ. Весело, правда?

Решений два: либо асинхронная обработка на стороне 1С (если есть кому это настроить), либо промежуточный буфер. Мы обычно идём вторым путём: webhook падает в быстрое хранилище (Redis, база данных), сразу отвечаем «принято», а 1С потом забирает данные оттуда в своём черепашьем темпе.

О том, как синхронизировать товары и цены с 1С без вечного «разъезда» справочников, мы расскажем в следующей статье этого цикла.

Платёжные системы и критичность дедупликации

Когда в игру вступают деньги — дедупликация становится вопросом жизни и смерти. Дублирование платежа может обернуться двойным списанием с клиента (и его праведным гневом), или двойным начислением на ваш счёт (и проблемами с бухгалтерией и налоговой). Оба варианта — полный кошмар.

Платёжные шлюзы (Kaspi Pay, Halyk Bank, ForteBank и другие) обычно шлют payment_id или transaction_id. Используйте их как ключ идемпотентности! И ещё важно: храните историю обработанных транзакций минимум год. Это вам понадобится для сверок, расследований спорных ситуаций и вообще для спокойного сна.

Как понять, что дедупликация работает

Ок, защиту настроили — замечательно. Но как понять, что она реально работает, а не просто создаёт иллюзию безопасности? И как узнать о проблемах раньше, чем узнает клиент?

За чем следить

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

Второе — время обработки webhook. От момента получения до ответа. Если больше пяти секунд — готовьтесь к повторам. Нетерпеливые отправители решат, что вы умерли, и пошлют ещё раз.

Третье — размер очереди на обработку. Сколько webhook'ов томятся в ожидании своей участи? Если число растёт — worker'ы не справляются, пора добавлять мощности или оптимизировать код.

Четвёртое — ошибки обработки. Сколько webhook'ов упало с ошибкой? Если внезапно выросло — либо баг в вашей бизнес-логике, либо отправитель начал слать что-то странное и неожиданное.

Алерты, которые спасут от хаоса

Обязательно настройте уведомления на такие события:

  • Всплеск дублей — больше 10% запросов за последний час отклонено как дубли. Что-то явно пошло не так на стороне отправителя, надо разбираться.
  • Рост очереди — больше 1000 необработанных webhook'ов висят в ожидании. Ваша система не справляется с нагрузкой.
  • Критические ошибки — webhook'и, связанные с платежами, падают с ошибкой. Тут вообще надо реагировать мгновенно!

О том, как построить нормальную систему мониторинга и дашбордов, мы подробно писали в статье про наблюдаемость LLM-систем — принципы там те же, просто контекст другой.

Нужна надёжная интеграция?

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

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

Чек-лист: webhook-интеграция без дублей

Используйте этот список при проектировании новой интеграции или аудите существующей.

Если вы получаете webhooks

  • Отвечаете быстро (< 5 сек), даже если обработка занимает больше времени
  • Проверяете idempotency_key или external_id перед обработкой
  • Сохраняете сырые webhook'и для отладки и аудита
  • Бизнес-логика также защищена от дублей (проверка по external_id)
  • Настроен мониторинг: количество дублей, ошибки, время обработки

Если вы отправляете webhooks

  • Генерируете уникальный idempotency_key для каждого события
  • Используете экспоненциальный backoff с jitter для ретраев
  • Ограничиваете количество попыток (не бесконечные ретраи)
  • Недоставленные webhook'и попадают в dead letter queue
  • Есть алерты на массовые неудачи доставки

Заключение: инвестиция в спокойный сон

Идемпотентность и правильная работа с ретраями — это не какая-то продвинутая техника для senior-разработчиков с десятью годами опыта. Это базовая гигиена. Как мыть руки перед едой, только для интеграций. Любая система, которая общается через интернет (а интернет — штука крайне ненадёжная, напомню), должна уметь переваривать повторы.

Да, реализация потребует времени. Таблицу для хранения ключей завести, логику проверки написать, мониторинг прикрутить. Но это инвестиция, которая окупится многократно. Никаких дублей в CRM и 1С — только чистые данные. Никакой еженедельной «чистки» мусора из базы вручную. Никакой паники при сбоях — система сама восстановится и переварит очередь. И никаких злых клиентов, которые получили три SMS подряд или обнаружили тройное списание с карты.

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

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