Справочник по Bot API
Один и тот же апдейт может прийти дважды
Telegram доставляет апдейты не менее одного раза. Webhook, который отвечает медленно, отваливается по таймауту или возвращает не 200, получит тот же апдейт снова, и порядок между разными апдейтами ничем не гарантирован. В каждом объекте `Update` есть монотонно растущий `update_id`, и это единственный надёжный ключ дедупликации.
Доставка не менее одного раза: точные цифры
- Гарантия доставки
- Не менее одного раза, порядок между разными апдейтами не гарантирован
- Доставка
- Не менее одного раза — дубликаты ожидаемы, а не исключительны
- Порядок
- Между разными апдейтами не гарантирован
- Ключ дедупликации
- update_id, монотонно растущий
- Где его хранить
- Долговременно. Множество в памяти пусто после деплоя
- Записи при выдаче
- Условный UPDATE, никогда не чтение с последующей записью
As of 2025-10-01, Telegram Bot API 13.4
Что это значит на практике
Доставка не менее одного раза — свойство любой надёжной системы обмена сообщениями, и это не дефект, но её обычно обходят, а не проектируют под неё. Порождаемый ею сбой конкретен и дорог: дважды обработанное подтверждение платежа дважды пополняет счёт, дважды выполненный обработчик выдачи раздаёт два места из ограниченного пула, а дважды отправленное приветствие всего лишь выглядит небрежно. Что именно вам достанется, зависит исключительно от того, что делает обработчик, а не от того, как часто приходит дубликат.
`update_id` — ключ дедупликации, и хранить его надо на диске, а не в памяти. Множество внутри рабочего процесса пустеет после следующего деплоя, а самая важная повторная доставка — как раз та, что приходит во время перезапуска. Правильная форма — вставка с ограничением уникальности по `update_id`: если вставка конфликтует, апдейт уже обработан и обработчик выходит, не повторяя работу.
Оговорка про порядок — отдельная, и она подводит тех, кто сначала решил проблему дубликатов. Два разных апдейта могут быть обработаны не в том порядке, особенно при параллелизме, когда с одного webhook читают несколько воркеров. Прочитать баланс и записать его обратно двумя операциями не атомарно, поэтому бот, который читает остаток, решает, что его достаточно, и затем уменьшает, под конкурентной нагрузкой раздаст больше, чем есть. Лечится это условной записью в базе — уменьшить там, где остаток больше нуля, — а не проверкой с последующей записью.
Наивные повторы делают хуже обеим проблемам, и вот здесь вредит хорошо настроенный HTTP-клиент. Таймаут на `sendMessage` не говорит вам, доставлено сообщение или нет: соединение оборвалось, а отправка вполне могла пройти. Повтор без ключа — это ровно тот способ, которым подписчики получают рассылку дважды. Очередь должна записывать попытку до вызова и сверяться после, а не полагаться на исход запроса, который может вообще не сообщить о себе.
Практическое правило — делать обработчики идемпотентными по построению и перестать рассуждать о том, как часто придёт дубликат. Считайте, что каждый обработчик выполняется минимум дважды, что любые два из них могут выполниться в любом порядке, и проектируйте так, чтобы оба случая были безвредны. Такая позиция почти ничего не стоит на этапе разработки и убирает целый класс ошибок, которые вылезают только под той нагрузкой, которую никто не тестировал.
Как это обрабатывать в коде
// Deduplicate on update_id in the database, not in process memory.
async function handleOnce(update: { update_id: number }, env: Env): Promise<void> {
const claimed = await env.DB
.prepare('INSERT OR IGNORE INTO seen_updates (update_id, at) VALUES (?, ?)')
.bind(update.update_id, Date.now())
.run()
// No row inserted means this update_id was already handled. A restart between the
// insert and the work still redelivers, which is why the work must also be idempotent.
if (claimed.meta.changes === 0) return
await doWork(update)
}
// Allocation under concurrency: one conditional write, never read-then-write.
async function claimSlot(env: Env, poolId: string): Promise<boolean> {
const result = await env.DB
.prepare('UPDATE pools SET remaining = remaining - 1 WHERE id = ? AND remaining > 0')
.bind(poolId)
.run()
return result.meta.changes === 1
}Какие вопросы это порождает
Как часто дубликат апдейта приходит на самом деле?
Редко, и именно это делает его опасным. Он собирается вокруг тех моментов, когда система и так под нагрузкой, — медленный обработчик, деплой, таймаут, — поэтому его нет ни в одном тесте и он есть во время инцидента. Заложиться на него дёшево; разбираться после двойного списания — нет.
Уникален ли update_id навсегда или он сбрасывается?
Он растёт монотонно в пределах бота и годится как ключ дедупликации на любом интересном вам интервале. Хранить ограниченную историю — дни, а не вечность — обычно достаточно, поскольку повторная доставка случается внутри окна повторов, а не спустя недели.
Избавляет ли быстрый ответ 200 от дубликатов апдейтов?
Он убирает самую частую причину, ведь Telegram повторяет доставку туда, где всё выглядит нездоровым. Идемпотентным обработчик от этого не становится, и деплой, перезапустивший процесс посреди обработки, всё равно вызовет повторную доставку частично сделанной работы. Быстрое подтверждение и идемпотентность дополняют друг друга, а не заменяют.
Можно ли рассчитывать, что апдейты придут в порядке событий?
Нет, и это та половина проблемы, которая переживает дедупликацию. Два сообщения, отправленные подряд, могут быть обработаны в любом порядке, и это важно всякий раз, когда второе зависит от первого. Там, где последовательность действительно значима, её надо восстанавливать по содержимому сообщений, а не предполагать по порядку прибытия.
Смежные ограничения
Что Telegram сообщит и чего не сообщит о сообщении после отправки, разобрано в заметке об отсутствии подтверждений доставки.
Единственный отказ, о котором Telegram сообщает явно, и что с ним делать, описывает разбор ошибки блокировки бота пользователем.
Та же гарантия в сторонних системах и способы сверки по обеим сторонам обсуждаются на странице интеграции по webhook.