handbookНаписать

patterns/enterprise/outbox/practice-telegram/README.md · практика · 4 языка · 19 тестов

Практика: уведомления клиентам в 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 такие ошибки летят наружу, в Go Run возвращает их обёрнутыми. Сообщение остаётся 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 не ходит вовсе.

Структура ​

one transaction

pending

sendMessage

Order

+id: int

+chatId: int

+totalRub: int

Notification

+id: int

+key: string

+chatId: int

+text: string

+status: Status

+error: string

Database

+orders: map

+outbox: Notification[]

-keys: map

+transaction(work)

+saveOrder(order)

+enqueue(key, chatId, text) : bool

+pending(limit) : Notification[]

OrderService

+placeOrder(order)

«interface»

BotApi

+sendMessage(chatId, text)

FakeBotApi

+sent: string[]

+calls: int

+block(chatId)

+throttleOnce(retryAfter)

TelegramSender

-batchSize: int

+pausedUntil: int

+run(now) : int

Ответы Bot API ​

Telegram отвечает на sendMessage объектом с ok, error_code и description. Для отправителя важны три исхода:

ОтветОшибка в кодеЧто делает отправитель
ok: trueнетsent
429, parameters.retry_afterTooManyRequests с retryAfterпауза, проход окончен, строка остаётся pending
403 Forbidden: bot was blocked by the user, 400 chat not foundChatUnavailabledead с текстом, проход продолжается

Пауза хранится в отправителе, а не в таблице. После перезапуска воркера первый же запрос получит 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 в панели кода.

Реализации ​

ЯзыкОсобенность
PHPenum 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>.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS