Практика: уведомления клиентам в Telegram (Telegram notifications)
Уведомление о заказе пишется в outbox одной транзакцией с заказом, а фоновый отправитель шлёт его через Bot API: пачками, с паузой на лимит, с пометкой мёртвых чатов и без дублей благодаря ключу идемпотентности.
Базовый пример: Outbox. Там получатель - брокер, который либо работает, либо лежит. Здесь получатель - внешний API со своими правилами: он отвечает "подожди N секунд", навсегда отказывает в отдельных чатах, а сам по себе дубли не отсекает, поэтому уникальность сообщения держит outbox.
Задача
Магазин пишет клиенту в Telegram, когда заказ принят. Первая версия звала Bot API прямо из обработчика заказа. Когда API тормозил, тормозило и оформление; когда отвечал ошибкой, заказ сохранялся, а сообщение пропадало. В распродажу бот упёрся в лимит Telegram, получил 429 Too Many Requests, и обработчик стал повторять запросы в цикле, отчего лимит только продлевался. Клиенты, которые заблокировали бота, давали 403 на каждой попытке, и эти сообщения повторялись бесконечно.
Последняя проблема - дубли. Мобильное приложение магазина повторяет POST /orders, если не дождалось ответа. Заказ сохраняется по номеру и не задваивается, а вот второе "заказ принят" клиент получал.
Что урезано
- База - словари и списки в памяти, транзакция снимает их копию и восстанавливает при ошибке, как в базовом примере. Уникальный индекс по ключу идемпотентности - отдельный словарь
keys. - Bot API - заглушка
FakeBotApi: она сразу бросает типизированные ошибки, как сделал бы настоящий клиент, разобравerror_codeиparameters.retry_afterиз ответа. HTTP, JSON и токен бота убраны. - Время - целые тики, которые передаются в
run(now). В проекте это секунды из часов воркера. - Сетевые сбои и
5xxне разобраны отдельно: в PHP, Python и TypeScript такие ошибки летят наружу, в GoRunвозвращает их обёрнутыми. Сообщение остаётсяpendingи уйдёт на следующем проходе. - Нет шаблонов, языка клиента и разметки сообщения: текст собирается одной строкой.
Решение
OrderService.placeOrder в одной транзакции сохраняет заказ и вызывает enqueue с ключом order:<id>:accepted. Если строка с таким ключом уже есть, enqueue ничего не пишет и возвращает false. Это тот же INSERT ... ON CONFLICT DO NOTHING по уникальному индексу, только в памяти. Ключ описывает событие, а не попытку: повтор запроса, повтор обработчика или повторное событие о том же заказе дают тот же ключ.
Отправитель TelegramSender за один вызов run(now) берёт пачку из batchSize самых старых pending-сообщений и шлёт их по порядку. На каждый ответ API у него своё решение:
- успех - строка
sent; TooManyRequests(429) - пауза доnow + retryAfter, проход останавливается: лимит на бота, следующие запросы получат тот же ответ;ChatUnavailable(400 или 403: чат удалён, бот заблокирован) - строкаdeadс текстом ошибки, проход идёт дальше: повторять бессмысленно, а остальным клиентам этот чат не мешает.
Пока пауза не кончилась, run возвращает 0 и в API не ходит вовсе.
Структура
Ответы Bot API
Telegram отвечает на sendMessage объектом с ok, error_code и description. Для отправителя важны три исхода:
| Ответ | Ошибка в коде | Что делает отправитель |
|---|---|---|
ok: true | нет | sent |
429, parameters.retry_after | TooManyRequests с retryAfter | пауза, проход окончен, строка остаётся pending |
403 Forbidden: bot was blocked by the user, 400 chat not found | ChatUnavailable | dead с текстом, проход продолжается |
Пауза хранится в отправителе, а не в таблице. После перезапуска воркера первый же запрос получит 429 с актуальным retry_after, и это стоит одного запроса. Мёртвые строки хранятся в таблице: их видит поддержка, и по ним можно снять у клиента флаг "уведомлять в Telegram".
Как это собирается
В приложении две точки входа над одной базой, как в базовом примере. Обработчик POST /orders получает OrderService и зовёт placeOrder, ответ клиенту зависит только от транзакции в базе. Фоновый воркер получает TelegramSender с той же базой и клиентом Bot API и по таймеру зовёт run со своим текущим временем. Корень приложения создаёт Database и клиент API (в проекте - пул соединений и HTTP-клиент с токеном бота) и раздаёт обоим.
В примере приложение магазина дважды присылает заказ 1, а клиент из чата 200 заблокировал бота. В таблице три строки, а не четыре. Первый проход упирается в лимит и встаёт на паузу до тика 3, второй проход на тике 1 в API не ходит. На тике 3 два сообщения уходят, а сообщение для чата 200 помечается мёртвым с текстом ошибки от Telegram.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
| Язык | Особенность |
|---|---|
| PHP | enum Status, Notification с public private(set) статусом, два catch по типам ошибок API |
| Go | Ошибки API - типы TooManyRequestsError и ChatUnavailableError, разбор через errors.As в switch; прочие ошибки Run возвращает с %w |
| Python | Транзакция - @contextmanager, StrEnum для статуса, BotApi - Protocol |
| TypeScript | Статус - объединение литералов, catch получает unknown и сверяет instanceof, чужие ошибки пробрасывает |
Запуск: make test T=patterns/enterprise/outbox/practice-telegram
Что проверяют тесты
Тесты одинаковы во всех языках, пачка из двух сообщений, три заказа в чаты 100, 200 и 300. Пачки идут по порядку: первый проход отправляет два сообщения, второй - третье. Лимит: API отвечает 429 с retry_after 5, отправитель встаёт на паузу до тика 5, проход на тике 4 не делает ни одного запроса, на тике 5 уходит пачка. Заблокированный чат: сообщение для чата 200 помечено dead с текстом ошибки, остальные дошли, а повторного запроса в мёртвый чат нет - всего три вызова API на три сообщения. Повтор заказа: placeOrder трижды с одним заказом, в том числе после отправки, - в таблице одна строка, в Telegram одно сообщение.
Где граница
- Ключ защищает от повторной записи, но не от повторной доставки. Если воркер упадёт после ответа Telegram и до отметки
sent, сообщение уйдёт ещё раз: Bot API ключей идемпотентности не принимает. Это окно сужают отметкой сразу после ответа и хранениемmessage_id, но закрыть его можно только на стороне получателя, как в практике про надёжного отправителя. - Лимиты бывают разные: общий на бота и на отдельный чат или группу. Когда чатов много и 429 приходят по одному чату, пауза на весь отправитель тормозит всех. Тогда паузу хранят на чат, в строке outbox (
not_before), а выборка пропускает отложенные. - Уведомлений много и каналов несколько (Telegram, SMS, почта): outbox остаётся, а над ним появляется маршрутизация по каналу и отдельный отправитель на каждый.
- Уведомление не обязано дойти ("товар снова в наличии" для сотни подписчиков): хватит фоновой задачи без outbox, потеря одного сообщения не стоит отдельной таблицы.
Отличия между языками
- PHP -
retryAfter-public readonlyсвойство исключения, отправитель читает его прямо вcatch. Статус строки меняют только методыmarkSentиmarkDead, снаружи он доступен на чтение. - Go - ошибки API - типы с полями, а не сентинелы: отправителю нужно значение
RetryAfter, иerrors.Asдостаёт его даже из обёрнутой ошибки.switchбез выражения держит все исходы ответа в одном месте. - Python - транзакция - блок
with, откат на любом исключении, включаяKeyboardInterrupt, поэтому ловитсяBaseException. Статус и текст ошибки ставятся одним присваиванием кортежа. - TypeScript -
catchполучаетunknown: отправитель сначала проверяетTooManyRequestsError, потомChatUnavailableError, всё остальное пробрасывает.transactionпринимает функцию и возвращает её результат, с асинхронным драйвером сигнатура станетPromise<T>.
Связанные темы
- Outbox (базовый пример)
- Практика: надёжный отправитель outbox - другая сторона доставки: потерянные подтверждения, дубли у получателя, порядок по заказу
- Практика Команды: команды прибору учёта по радио - очередь запросов с повтором по таймауту и пределом попыток, только с подтверждением от получателя