Если Slack-бот по одному сообщению создает две или три одинаковые задачи в CRM, Jira, трекере или собственной базе, причина обычно не в пользователе. Одно и то же событие могло быть доставлено повторно, два обработчика могли сработать параллельно, а внешний сервис мог создать задачу и не успеть вернуть ответ. Простая проверка заголовка повторной доставки уменьшает число дублей, но не делает интеграцию надежной. Нужна идемпотентная обработка на всех этапах: прием события, очередь и запись результата.

Исправление стоит начинать не с удаления дублей вручную, а с восстановления цепочки конкретного события. Для одного примера нужно связать Slack event_id, workspace, время приема, задание очереди и идентификатор созданной задачи. После этого станет видно, повторил ли запрос Slack, перезапустилась ли очередь или интеграция сама дважды вызвала API целевой системы.

Как выглядит проблема

  • после одного сообщения или реакции появляются две одинаковые задачи;
  • дубликат создается почти сразу либо через одну или пять минут;
  • повтор возникает только при медленной работе CRM или трекера;
  • в журнале Slack-обработчика виден один запрос, но очередь выполняет задание несколько раз;
  • дубли появляются после масштабирования приложения на несколько процессов;
  • бот отвечает в канал, а его собственное сообщение снова запускает обработчик;
  • одна команда обрабатывается одновременно как событие, интерактивное действие или slash-команда.

Интервал между копиями дает первую подсказку. Почти одновременные дубли чаще связаны с конкурентными обработчиками или двумя экземплярами приложения. Повтор через несколько секунд или минут похож на повторную доставку после тайм-аута. Дубликат после сбоя воркера обычно указывает на повтор задания очереди или слишком раннюю отметку о выполнении.

Почему Slack повторяет событие

Для Events API приложение должно быстро подтвердить прием события ответом HTTP 2xx. Если endpoint отвечает дольше трех секунд, возвращает ошибку, недоступен по сети или имеет проблему с TLS, Slack считает попытку неуспешной и повторяет доставку. В заголовках повторного запроса передаются X-Slack-Retry-Num и X-Slack-Retry-Reason. Причина http_timeout особенно характерна для обработчиков, которые сначала создают задачу во внешней системе и только затем отвечают Slack.

Представим, что CRM создала задачу за две секунды, а формирование ответа заняло еще две. Slack не получил подтверждение вовремя и повторил событие. Первый процесс уже создал запись, второй запускает ту же операцию снова. Поэтому endpoint приема не должен ждать внешние API, отправку писем, генерацию отчета или длительную работу с базой. Его задача — проверить запрос, надежно зарегистрировать событие, поставить его в очередь и сразу вернуть успешный ответ.

Другие причины дублей

Очередь повторяет задание

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

Внешний API вернул неоднозначный тайм-аут

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

Работают два обработчика

После миграции или масштабирования старый процесс может остаться активным вместе с новым. В Socket Mode к приложению могут подключиться несколько потребителей, в Events API — работать два маршрута внутри приложения, а в коде один listener может быть зарегистрирован дважды. Отдельно проверьте тестовую и рабочую установки Slack-приложения в одном workspace.

События разных типов считаются одной командой

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

Что собрать для диагностики

Выберите один подтвержденный дубль и соберите связанные записи. Не помещайте в журнал signing secret, OAuth-токен, полный текст приватной переписки и персональные данные, если они не нужны для расследования. Обычно достаточно технических идентификаторов.

  • event_id, team_id или workspace_id, api_app_id и тип события;
  • channel_id, message timestamp и thread timestamp, если событие относится к сообщению;
  • X-Slack-Retry-Num и X-Slack-Retry-Reason;
  • время начала и завершения HTTP-запроса, статус ответа;
  • внутренний request_id и идентификатор задания очереди;
  • номер попытки воркера и причина предыдущего сбоя;
  • стабильный ключ исходного события в целевой задаче;
  • идентификаторы всех созданных копий в CRM или трекере;
  • версия приложения и имя экземпляра, который выполнял операцию.

Если у дублей один event_id, повтор произошел после приема события или в очереди. Если event_id разные, сравните тип, subtype, channel и message timestamp: возможно, приложение объединяет разные события в одну бизнес-команду. Если журнал содержит только одну попытку, а задач две, ищите повтор внутри клиента целевого API, callback или автоматизацию уже в самой CRM.

Безопасный прием событий Slack

Сначала проверяйте X-Slack-Signature по необработанному телу запроса и signing secret, а также допустимое отклонение X-Slack-Request-Timestamp. Это защищает endpoint от поддельных и повторно воспроизводимых запросов. Signing secret хранится в менеджере секретов или переменной окружения и никогда не записывается в базу событий.

После проверки подписи извлеките workspace и event_id. Вставьте событие в таблицу приема с уникальным ограничением. Если запись уже существует, верните HTTP 200 и не ставьте второе задание. Только успешная новая вставка должна создавать работу в очереди. Ответ Slack следует отправить сразу, не ожидая выполнения бизнес-операции.

HTTP endpoint verify signature and request timestamp parse workspace_id and event_id insert inbound_event with unique(provider, workspace_id, event_id) if inserted: enqueue inbound_event.id return HTTP 200 Worker atomically claim inbound_event create or find task by source_event_key commit result and mark event processed

Заголовки X-Slack-Retry-Num и X-Slack-Retry-Reason полезны для диагностики и метрик, но их не следует использовать как единственный механизм дедупликации. Первый запрос не содержит номера повтора, а повтор может возникнуть внутри вашей очереди. Основным ключом входного события служит event_id в контексте приложения или workspace, закрепленный уникальным индексом базы данных.

Минимальная таблица входящих событий

  • provider — источник, например slack;
  • workspace_id — идентификатор рабочего пространства;
  • event_id — идентификатор события Slack;
  • event_type и event_subtype — тип и подтип;
  • status — received, processing, processed или failed;
  • attempt_count — число запусков воркера;
  • received_at, processing_started_at и processed_at;
  • result_type и result_id — созданный объект;
  • last_error_code — классифицированная ошибка без секретов.

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

Как сделать создание задачи идемпотентным

Дедупликация только на входе не покрывает падение воркера после создания задачи. Добавьте в таблицу задач поле source_event_key и уникальный индекс. Ключ можно построить из провайдера, workspace и event_id. Повторный запуск должен находить уже существующую задачу и завершаться успешно, а не создавать новую.

  1. Воркер атомарно получает событие со статусом received или доступное для повтора failed.
  2. Формирует стабильный source_event_key, который не меняется между попытками.
  3. Проверяет существование результата по этому ключу.
  4. Создает задачу и сохраняет связь с событием в одной транзакции, если система локальная.
  5. Для внешнего API передает тот же idempotency key, если API поддерживает такую возможность.
  6. После неоднозначного тайм-аута сначала ищет объект по внешнему ключу, а не создает его повторно.
  7. Только после подтвержденного результата переводит входное событие в processed.

Если внешняя система не поддерживает idempotency key, сохраните собственную таблицу операций. Перед вызовом создайте запись planned с уникальным source_event_key, после ответа сохраните внешний ID. При тайм-ауте переведите операцию в unknown и запустите сверку: поиск по метке, пользовательскому полю или временному окну. Слепой повтор в состоянии unknown опасен.

Транзакции и шаблон outbox

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

Не отмечайте событие обработанным до фиксации результата. Иначе сбой между отметкой processed и созданием задачи приведет уже не к дублю, а к потере задачи. Статусы должны отражать реальность: received, processing, processed, retryable_error, permanent_error и unknown_result.

Настройка повторов очереди

  • повторяйте только временные ошибки: сетевой сбой, 429, 502, 503 или подтвержденный тайм-аут до обработки;
  • для временных ошибок используйте увеличивающуюся задержку и небольшой случайный jitter;
  • ограничьте число попыток и отправляйте исчерпанные задания в dead-letter очередь;
  • ошибки валидации, прав доступа и отсутствующих обязательных полей не повторяйте бесконечно;
  • освобождайте зависшие processing-задания по lease, но сохраняйте номер попытки;
  • различайте повтор доставки Slack и повтор выполнения внутренней очереди;
  • не удаляйте запись входного события при временной ошибке.

Защита от гонок нескольких воркеров

Два воркера могут одновременно увидеть событие received и оба начать обработку. Получение задания должно быть атомарным: транзакционная блокировка строки, SELECT FOR UPDATE SKIP LOCKED, compare-and-set статуса или штатная семантика брокера. Однако блокировка не заменяет уникальный ключ результата: процесс способен завершиться после внешнего вызова и до подтверждения очереди.

При горизонтальном масштабировании добавьте в журнал instance_id и deployment_version. Это быстро показывает, выполняли ли событие разные версии приложения. После развертывания убедитесь, что старые контейнеры завершены, а тестовый endpoint не подписан на рабочий workspace.

Фильтрация событий и защита от циклов

  • обрабатывайте только явно разрешенные event type и subtype;
  • отдельно решите, нужны ли message_changed, message_deleted и thread replies;
  • игнорируйте сообщения с bot_id вашего приложения, если они не являются командой;
  • проверяйте app_id и workspace, чтобы тестовое приложение не создавало рабочие задачи;
  • не связывайте бизнес-действие только с текстом сообщения;
  • для кнопок и slash-команд используйте собственный стабильный идентификатор действия;
  • не запускайте создание задачи одновременно из Events API и из callback интерактивного компонента.

Хэш текста сообщения — плохой ключ: одинаковые обращения могут быть законно отправлены разными людьми, а редактирование пробела меняет хэш. Идентификатор источника и контекст workspace надежнее и не требуют хранить содержимое переписки.

Пошаговый план исправления

  1. Зафиксируйте один дубль и свяжите Slack event_id с идентификаторами обеих задач.
  2. Проверьте retry-заголовки, время ответа endpoint и состояние очереди.
  3. Убедитесь, что подпись проверяется по исходному телу, а секреты не попадают в журнал.
  4. Вынесите длительную работу из HTTP endpoint и возвращайте 2xx после надежной регистрации события.
  5. Добавьте таблицу inbound_events с уникальным индексом по источнику, workspace и event_id.
  6. Ставьте задание в очередь только после новой вставки события.
  7. Добавьте source_event_key и уникальный индекс к создаваемой задаче или операции.
  8. Разделите временные, постоянные и неоднозначные ошибки внешнего API.
  9. Проверьте число активных обработчиков, установок приложения и подписок.
  10. Отфильтруйте ненужные subtype и собственные сообщения бота.
  11. Прогоните повторную доставку и сбои на тестовом workspace.
  12. Добавьте метрики дублей, задержки очереди и неизвестных результатов.

Как проверить исправление

Проверка должна воспроизводить не только обычный запрос, но и точки отказа. Используйте тестовый workspace и обезличенные данные. Не отправляйте вручную запросы с рабочим signing secret и не отключайте проверку подписи ради теста.

  • дважды передайте один и тот же валидный event_id — должна появиться одна задача;
  • отправьте одинаковое событие одновременно в несколько процессов;
  • задержите воркер, но убедитесь, что HTTP endpoint отвечает Slack вовремя;
  • завершите воркер после создания локальной задачи и до подтверждения очереди;
  • имитируйте тайм-аут после успешного создания объекта во внешней системе;
  • повторите задание очереди несколько раз;
  • отправьте message_changed, ответ в треде и сообщение самого бота;
  • перезапустите приложение и Redis, затем повторите старый event_id;
  • проверьте, что постоянная ошибка попала в dead-letter, а не повторяется бесконечно.

Результат считается устойчивым, если все повторные попытки завершаются успешно с тем же result_id, а число бизнес-объектов остается равным одному. Простое отсутствие дубля при одном ручном тесте не подтверждает идемпотентность.

Что контролировать после запуска

  • долю событий с конфликтом уникального event_id;
  • число X-Slack-Retry-Reason по причинам;
  • 95-й и 99-й процентиль времени ответа входного endpoint;
  • задержку и глубину очереди;
  • число попыток на одно событие;
  • конфликты уникального source_event_key в задачах;
  • операции со статусом unknown и время их сверки;
  • размер dead-letter очереди;
  • события, созданные собственным bot_id;
  • число активных экземпляров и версий обработчика.

Типичные ошибки

  • отвечать Slack только после создания задачи;
  • считать X-Slack-Retry-Num полноценной дедупликацией;
  • хранить обработанные event_id только в памяти или в Redis с коротким TTL;
  • отмечать событие processed до фиксации результата;
  • повторять любой тайм-аут как гарантированно невыполненную операцию;
  • использовать текст сообщения или timestamp без workspace как уникальный ключ;
  • выключать повторы Slack вместо исправления медленного endpoint;
  • запускать два listener без общего уникального индекса;
  • логировать OAuth-токены, signing secret и полное содержимое каналов;
  • удалять дубли вручную, не связывая их с исходным event_id.

Как предотвратить повторение

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

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

Когда нужна помощь с интеграцией Slack

Если Slack-бот создает дубли задач, я могу разобрать цепочку event_id → endpoint → очередь → CRM, найти место повторной обработки и внедрить идемпотентность без потери заявок. Для оценки пришлите обезличенный пример дубля, тип события, retry-заголовки, время ответа endpoint, схему очереди и идентификаторы созданных задач. Секреты, токены и содержимое приватных каналов присылать не нужно.