Telegraft

Справочник по 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 повторяет доставку туда, где всё выглядит нездоровым. Идемпотентным обработчик от этого не становится, и деплой, перезапустивший процесс посреди обработки, всё равно вызовет повторную доставку частично сделанной работы. Быстрое подтверждение и идемпотентность дополняют друг друга, а не заменяют.

Можно ли рассчитывать, что апдейты придут в порядке событий?

Нет, и это та половина проблемы, которая переживает дедупликацию. Два сообщения, отправленные подряд, могут быть обработаны в любом порядке, и это важно всякий раз, когда второе зависит от первого. Там, где последовательность действительно значима, её надо восстанавливать по содержимому сообщений, а не предполагать по порядку прибытия.

Смежные ограничения