Как избежать дублей SMS при сбоях API и SMPP

Как избежать дублей SMS при сбоях API и SMPP

Дубли SMS появляются, когда система повторяет отправку после тайм-аута, хотя шлюз уже принял первое сообщение. Защита строится на идемпотентности: бизнес-система передаёт уникальный request_id, хранит результат операции и не создаёт новую отправку при повторе того же запроса. В статье разберём, чем отличаются request_id и message_id, где хранить ключи дедупликации, как обрабатывать повторы API и SMPP и какие проверки провести перед запуском.

Почему одно SMS отправляется дважды?

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

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

В SMPP похожая ситуация возникает при восстановлении соединения. Клиент отправил команду submit_sm, затем потерял соединение до получения ответа submit_sm_resp. После переподключения приложение не знает, принял ли шлюз сообщение, и повторяет передачу. Само переподключение не сообщает, была ли первая SMS доставлена.

Нужно разделять три события:

  • приложение передало запрос на отправку;
  • шлюз принял сообщение и вернул идентификатор;
  • оператор передал SMS абоненту или сообщил об ошибке доставки.

Отчёт о доставке не заменяет защиту от дублей. DLR помогает узнать статус уже принятого сообщения, но решение о повторной отправке принимают раньше, когда система обрабатывает тайм-аут или сетевую ошибку. О назначении DLR и правилах повторов подробно рассказывает материал про отправку SMS о доставке через SMPP.

Как связать request_id и message_id?

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

message_id возвращает SMS-шлюз после принятия сообщения. По нему можно искать статус, сопоставлять DLR и проверять журнал отправки. Он относится к конкретной попытке или принятому сообщению, поэтому его нельзя заранее использовать вместо собственного ключа дедупликации.

Идентификатор Кто создаёт Для чего нужен Когда появляется
request_id CRM, сайт или внутренний сервис Защищает бизнес-операцию от повторного запуска До первого запроса к шлюзу
message_id SMS-шлюз Связывает принятую SMS со статусами и DLR После приёма сообщения шлюзом
SMPP sequence_number SMPP-клиент Связывает запрос и ответ внутри соединения Во время конкретной SMPP-сессии

SMPP sequence_number помогает сопоставить submit_sm и submit_sm_resp, но не гарантирует защиту от дублей после разрыва соединения. После восстановления сессии новый sequence_number может относиться к той же бизнес-операции. Поэтому его нужно хранить вместе с request_id и полученным message_id, а не использовать как единственный ключ.

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

  • request_id — идентификатор операции подтверждения заказа;
  • номер получателя и тип сообщения;
  • текст или его контрольная сумма;
  • текущий статус: создано, отправлено, принято, доставлено, ошибка;
  • message_id шлюза, если он уже получен;
  • время первой попытки и последнего изменения статуса.

Если повторный запрос пришёл с тем же request_id, сервис возвращает сохранённый результат. Он не создаёт новую SMS. Если ключ уже связан с message_id, приложение продолжает отслеживать существующее сообщение.

Как реализовать дедупликацию в API?

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

  1. Сервис формирует request_id до обращения к SMS-шлюзу.
  2. В базе создаётся запись со статусом «создано».
  3. Сервис проверяет, не существует ли такой ключ.
  4. Если ключ уже завершён или связан с message_id, возвращается прежний результат.
  5. Если ключ новый, сервис помещает задачу в очередь отправки.
  6. После ответа шлюза запись получает message_id и новый статус.

Проверка ключа и его создание должны проходить атомарно. Иначе два параллельных HTTP-запроса успеют одновременно увидеть, что записи нет, и оба добавят SMS в очередь. На уровне базы данных для request_id нужен уникальный индекс.

Пример логики можно описать так:

  • ключ найден со статусом «доставлено» — вернуть сохранённый результат;
  • ключ найден со статусом «принято» — не отправлять повторно, вернуть message_id;
  • ключ найден со статусом «в работе» — не создавать вторую задачу;
  • ключ найден со статусом «временная ошибка» — разрешить повтор по правилам;
  • ключ не найден — создать операцию и передать её обработчику.

Статус «временная ошибка» требует отдельной политики. Повтор допустим, если шлюз явно сообщил, что запрос не принят. При сетевом тайм-ауте результат неизвестен, поэтому безопаснее сначала запросить статус по сохранённым данным или передать операцию в разбор. Автоматический повтор вслепую создаёт главный риск дубля.

Для фоновой отправки полезна очередь задач. Она отделяет пользовательский запрос от сетевого обмена со шлюзом и позволяет повторять обработку без повторного создания бизнес-операции. В задаче очереди хранится request_id, а не только номер телефона и текст.

Как обработать повторы в SMPP?

На SMPP-соединении клиент отслеживает каждую команду submit_sm и ответ submit_sm_resp. Если ответ пришёл, message_id сохраняют сразу. Если соединение оборвалось, запись переводят в состояние «результат неизвестен», а не в безусловную ошибку.

Для каждой отправки полезно хранить:

  • request_id бизнес-системы;
  • SMPP sequence_number текущей сессии;
  • message_id из ответа шлюза;
  • время передачи команды;
  • код ответа SMPP;
  • число попыток и причину повтора.

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

Нельзя считать успешную доставку единственным критерием для принятия решения. SMS может быть принята шлюзом, но DLR придёт позже. Поэтому состояния «принято» и «доставлено» должны храниться отдельно. Такая модель позволяет не отправлять второе сообщение, пока первое ещё ожидает отчёт.

Если бизнесу нужен прямой обмен с SMS-шлюзом, на этапе проектирования проверяют поддержку DLR, правила повторов, формат message_id и поведение соединения при восстановлении. Для API-подключения те же вопросы фиксируют в документации интеграции: где передаётся request_id, какой ответ возвращается при повторе и сколько времени хранится результат.

Какие ошибки чаще всего создают дубли?

  • Новый request_id при каждом повторе. Система воспринимает повторную попытку как новую операцию. Ключ создают один раз и передают дальше без изменений.
  • Использование номера телефона как ключа. Один клиент может получить несколько разных уведомлений. Номер не описывает конкретный заказ или событие.
  • Отсутствие уникального ограничения в базе. Проверка «есть ли запись» без атомарного создания не защищает от параллельных запросов.
  • Повтор после любого тайм-аута. Тайм-аут показывает отсутствие ответа, но не подтверждает, что шлюз отклонил сообщение.
  • Хранение только message_id. Если ответ потерян, приложение не сможет связать неизвестную попытку с бизнес-событием.
  • Смешение статусов принятия и доставки. DLR может прийти позже, поэтому эти события записывают раздельно.

Отдельно проверьте шаблоны и длину сообщения. Для латинского текста в SMPP используется GSM 03.38, а длина одного SMS составляет 160 символов (Stream Telecom). При другом алфавите или длинном тексте сообщение может разбиваться на части. Это не всегда дубль: несколько сегментов одной SMS нужно отличать от повторной отправки одной и той же операции.

Для тестирования подготовьте сценарии с потерей ответа, разрывом SMPP-сессии, двумя одинаковыми API-запросами и параллельной обработкой очереди. В журнале должно быть видно, почему сервис отправил или не отправил повтор, какой request_id использовал и какой message_id получил. Если интеграция строится вокруг готовой CRM или автоматических уведомлений, правила защиты лучше проверить на каждом триггере отдельно; практический разбор таких сценариев есть в материале об автоматизации SMS-напоминаний через SMPP.

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

3 шага, которые можно сделать на этой неделе:

  1. Добавить request_id к каждой операции, которая запускает SMS, и создать для него уникальное ограничение.
  2. Разделить в журнале статусы операции, принятия шлюзом и доставки.
  3. Провести тест с потерей ответа API и разрывом SMPP-соединения, затем проверить количество созданных сообщений и связку request_id с message_id.