Откуда берутся дубли
Когда я начал делать AI-агента, который реагирует на события из CRM и мессенджеров, всё было круто — пока поставщики не начали слать одно и то же событие по два-три раза. Bitrix, amoCRM, колбэки от GigaChat — все они работают по принципу «at least once». Сервер чуть задержался с ответом, сеть где-то потеряла подтверждение — и вот уже два одинаковых письма улетели клиенту.
В обычном сервисе это не страшно. А тут каждая обработка — это деньги на LLM, запись в pgvector и реальные действия. Дубль бьёт и по бюджету, и по репутации.
Что не сработало у меня сначала
Сначала я сделал простую проверку: есть ли уже такая запись — пропускаем. Работало до первого параллельного запроса. Два воркера одновременно видят, что записи нет, оба запускают обработку, и письмо уходит дважды.
Плюс я понадеялся на event_id от поставщика. Оказалось, Bitrix иногда шлёт одинаковый id для разных событий. Пришлось придумывать свой механизм.
Моя схема: ключ + блокировка + журнал
В итоге я собрал таблицу webhook_inbox с каноническим ключом идемпотентности. Ключ вычисляю сам из payload — по UUID звонка, Message-ID письма или ведру по времени (ts // 5), если естественного идентификатора нет.
Захват ключа делаю через INSERT ... ON CONFLICT. Если ключ уже есть и обработка успешна — просто возвращаю 200. Если упала — даю шанс повторить. Если висит больше пяти минут — перехватываю.
Поставщику всегда отвечаю 200, даже если это дубль. Так меньше ретраев и меньше шума.
Идемпотентность исходящих действий
Отдельная история — сами действия. Когда агент решает отправить письмо, я тоже проверяю ключ операции. Даже если обработчик запустился дважды, письмо уйдёт только один раз.
Что получилось
После внедрения дубли писем исчезли. Расходы на LLM упали примерно на 12 %. Теперь спокойно запускаю несколько реплик обработчика — они не мешают друг другу. Упавшие задачи подхватываются автоматически при следующей доставке.
Главный вывод: идемпотентность нужно закладывать в самую основу пайплайна, а не прикручивать потом. Иначе клиент рано или поздно получит три одинаковых счёта, и объяснять это будет неловко.
