Перейти к содержанию
13 минут чтения
Тема: Обработка ошибок Telegram Bot API

Ошибка 429 в Telegram Bot API: лимиты, retry_after и повторная отправка

Бот работал, но при отправке уведомлений появились Too Many Requests и error_code 429. Разберём, кто ограничил запросы, где прочитать время ожидания и как возобновить отправку без бесконечных повторов и перегрузки очереди.

Короткий ответ

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

Для ответа Telegram читайте parameters.retry_after. Управляйте скоростью всех отправителей одного бота: пауза только в одном worker-е не поможет, если остальные продолжают ту же рассылку.

1. Найдите ответ Telegram и время ожидания

Сохраните HTTP-статус, заголовок Retry-After и поля JSON ok, error_code, parameters.retry_after. Полный запрос с ключами и текстом сообщения для этой диагностики не нужен.

При прямом обращении к Telegram ограничение обычно приходит с HTTP 429 и JSON с error_code 429. Вымышленный пример тела такого ответа:

JSON · пример Telegram flood control
{
  "ok": false,
  "error_code": 429,
  "description": "Too Many Requests: retry after 12",
  "parameters": {
    "retry_after": 12
  }
}

parameters.retry_after задаёт время ожидания в секундах до повтора. Используйте структурированное поле, а не извлечение числа из английского текста description. Определение дано в Telegram ResponseParameters.

Если запрос выполняет библиотека, сохраните тело ответа из HTTP-исключения: некоторые клиенты прерывают обычное выполнение при статусе 429. Время ожидания нужно брать из конкретного отказа, а не подставлять фиксированное число для всех сообщений.

HTTP 429 может вернуть и промежуточный сервер. HTML-страница от CDN не является ответом Telegram: сначала определите источник. Таймаут и 502 также требуют отдельной обработки — они не подтверждают отказ по лимиту, и результат отправки может остаться неизвестным.

2. Посчитайте нагрузку на каждом уровне

В FAQ Telegram для обычной отправки приведены ориентиры: избегать более одного сообщения в секунду в один чат, не превышать 20 сообщений в минуту в группу и около 30 сообщений в секунду для массовых уведомлений без платного увеличения лимита.

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

  • Суммируйте запросы всех worker-ов, cron-задач и экземпляров приложения с одним ботом.
  • Учитывайте проверки состояния, операции с файлами и повторные попытки.
  • Проверьте, не запускается ли одна рассылка дважды и не включён ли повтор одновременно в SDK, очереди и внешнем обработчике.
  • Посмотрите распределение по чатам: отправка в один активный групповой чат может упереться в его ограничение раньше общего.

Например, четыре worker-а по одному сообщению в секунду могут отправлять в один чат четыре сообщения в секунду. Каждый процесс по отдельности выглядит ненагруженным, но Telegram видит их суммарную активность. Ограничитель должен быть общим для этих процессов.

3. Отложите запрос и уменьшите скорость

Для уведомлений удобна очередь с плановым временем следующей попытки. Алгоритм:

  1. Подтвердите отказ именно по лимиту и определите его источник.
  2. Получите parameters.retry_after из ответа Telegram. Если применяется ещё один независимый лимит, соблюдайте и его время ожидания.
  3. Сохраните следующую попытку не раньше этого времени. Можно добавить небольшой случайный запас, но нельзя уменьшать требуемую паузу.
  4. Освободите worker. Не держите десятки процессов в sleep, ожидающих окончания одной паузы.
  5. Ограничьте число попыток и срок актуальности уведомления. После исчерпания бюджета оставьте запись для разбора.

Отложить только отклонённое задание недостаточно, если другие процессы продолжают отправку в тот же лимит. Храните общий cooldown в Redis или БД: для конкретного бота и, где это требуется, получателя. Проверка и резервирование очередного разрешённого слота должны учитывать конкурирующие процессы.

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

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

В Laravel для отложенной попытки можно использовать release($delay), в других очередях — их механизм планирования. Само наличие очереди не задаёт темп отправки: дополнительно нужен общий контроль скорости.

Пример разбора ответа на PHP без SDK

Следующая функция принимает HTTP-статус и исходное JSON-тело ответа Telegram. Она ничего не отправляет и не запускает повторы. Если ответ не получен, передайте статус 0 и null. Файл telegram-response-decision.php должен начинаться с <?php; тег в блоке опущен.

PHP · telegram-response-decision.php
declare(strict_types=1);

/**
 * @return array{state: string, after_seconds: ?int}
 */
function telegramResponseDecision(int $httpStatus, ?string $body): array
{
    $unknown = ['state' => 'unknown', 'after_seconds' => null];
    if ($body === null || $httpStatus < 200 || $httpStatus >= 500) {
        return $unknown;
    }

    try {
        $response = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
    } catch (JsonException) {
        return $unknown;
    }
    if (!is_array($response)) {
        return $unknown;
    }
    if ($httpStatus < 300 && ($response['ok'] ?? null) === true) {
        return ['state' => 'sent', 'after_seconds' => null];
    }

    $code = $response['error_code'] ?? null;
    if (($response['ok'] ?? null) !== false || !is_int($code) || $code < 1 || $code >= 500) {
        return $unknown;
    }
    if ($code !== 429) {
        return ['state' => 'failed', 'after_seconds' => null];
    }

    $parameters = is_array($response['parameters'] ?? null) ? $response['parameters'] : [];
    $wait = $parameters['retry_after'] ?? null;
    if (!is_int($wait) || $wait < 0 || $wait > PHP_INT_MAX - 3) {
        return ['state' => 'paused', 'after_seconds' => null];
    }

    return [
        'state' => 'retry',
        'after_seconds' => max(1, $wait) + random_int(1, 3),
    ];
}

Проверить функцию можно на вымышленном ответе, без запросов в Telegram:

PHP · пример вызова функции
$body = '{"ok":false,"error_code":429,"parameters":{"retry_after":12}}';
$decision = telegramResponseDecision(429, $body);
// state = retry; after_seconds — от 13 до 15 секунд.

Небольшой случайный запас добавляется к указанной паузе. Значение retry_after, равное нулю, не превращается в плотный цикл. Строка вместо числа, отрицательное значение и отсутствие задержки дают состояние paused для разбора.

  • sent — отправка подтверждена.
  • retry — сохраните время следующей попытки, обновите общую паузу и верните задание планировщику в пределах бюджета попыток.
  • paused — лимит подтверждён, но понятной задержки нет; требуется разбор или заранее выбранная ограниченная политика ожидания.
  • failed — исправьте запрос или настройки.
  • unknown — результат не установлен; автоматический повтор отправки не разрешён этой политикой.

Это разбор ответа обычного sendMessage, а не реализация очереди. Вызов функции не отправляет сообщение повторно и не защищает от двух одновременно работающих отправителей. Планирование, общий ограничитель, срок актуальности и учёт попыток остаются в приложении.

Что не исправляет ошибку 429

  • Увеличение сетевого таймаута. Сервер уже ответил отказом по частоте; ждать тот же ответ дольше не нужно.
  • Больше worker-ов. Без общего ограничителя это увеличивает суммарный поток и число отказов.
  • Немедленный повтор в цикле. Создаёт новые запросы до окончания требуемой паузы.
  • Замена прокси или ротация ключей ради обхода. Лимит отправки Telegram не исчезает от смены сетевого маршрута.
  • Удаление всей очереди. Это потеря уведомлений, а не управление скоростью. Устаревшие записи следует обрабатывать по правилам продукта.

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

Как проверить обработку без реальной перегрузки

Подставьте в тестах несколько ответов: успешный JSON, Telegram 429 с retry_after, лимит без задержки, ошибку параметров с кодом 400, отказ 401, HTML вместо JSON, 502 и отсутствие ответа. Проверьте, что только явный лимит с понятной паузой разрешает отложенный повтор.

Затем проверьте общий ограничитель с двумя worker-ами: пауза, установленная одним, должна учитываться другим. Убедитесь, что длительное ожидание не занимает процесс, а превышение числа попыток или срока уведомления завершает обработку.

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

Если вы используете BotGate

Через BotGate нужно различать два источника ограничений. Telegram-ошибка сохраняется в JSON, но приходит с HTTP 400; лимит самого сервиса возвращается с HTTP 429. Поэтому одной проверки HTTP-статуса недостаточно.

Ограничение BotGate

При превышении лимита самого шлюза ответ другой:

HTTP · пример лимита BotGate, числа условные
HTTP/1.1 429 Too Many Requests
Retry-After: 18
Content-Type: application/json

{"ok":false,"error":"Превышен лимит запросов"}

Для отдельного бота текст ошибки — «Превышен лимит запросов для этого бота». Здесь время берётся из числового HTTP-заголовка Retry-After; поля parameters.retry_after может не быть. Это разные места хранения одной по смыслу подсказки.

Для Telegram ищите ok: false, error_code: 429 и parameters.retry_after. Для собственного лимита BotGate время передаётся в числовом заголовке Retry-After. При применении нескольких ограничений соблюдайте каждую паузу.

Базовые ограничения сервиса — 600 запросов в минуту на API-ключ и 300 на бота. Учитываются запросы API, включая диагностику и повторы; актуальные условия описаны в документации. Для лимита ключа нужна общая пауза всех использующих его отправителей, даже если боты разные.

Разбор через PHP SDK

SDK предоставляет отдельные исключения для этих случаев:

  • RateLimitException — HTTP 429 от BotGate. Метод retryAfter() читает числовой заголовок Retry-After.
  • TelegramException с telegramErrorCode() === 429 — лимит Telegram внутри HTTP 400. Метод retryAfter() читает целое неотрицательное значение из parameters.retry_after; parameters() сохраняет параметры ошибки.

В обоих случаях неизвестное или некорректное время возвращается как null. У TelegramException httpStatusCode() и getCode() по-прежнему содержат HTTP 400 — для определения Telegram 429 используйте telegramErrorCode().

Стандартный транспорт автоматически повторяет HTTP 429, 502, 503 и 504, учитывая числовой Retry-After. Telegram 429 внутри HTTP 400 он автоматически не повторяет. Для собственного планировщика отключите встроенные повторы через maxRetries: 0, а в Laravel — BOTGATE_RETRY_MAX=0. Иначе обработчик увидит HTTP 429 BotGate только после завершения встроенных попыток.

Установка SDK: composer require botgate/sdk. Код ниже использует эти исключения, поэтому читать исходный HTTP-ответ вручную не требуется.

Пример обработки через PHP SDK

Следующая функция получает исключение SDK и возвращает решение для очереди. Она сама ничего не отправляет и не повторяет. Файл delivery-decision.php должен начинаться с <?php; тег в примере опущен.

PHP · delivery-decision.php
declare(strict_types=1);

use BotGate\Exception\BotGateException;
use BotGate\Exception\RateLimitException;
use BotGate\Exception\TelegramException;

/**
 * @return array{state: string, source: ?string, after_seconds: ?int}
 */
function telegramFailureDecision(BotGateException $error): array
{
    if ($error instanceof RateLimitException) {
        $source = 'botgate';
        $wait = $error->retryAfter();
    } elseif ($error instanceof TelegramException) {
        if ($error->telegramErrorCode() !== 429) {
            return ['state' => 'failed', 'source' => 'telegram', 'after_seconds' => null];
        }

        $source = 'telegram';
        $wait = $error->retryAfter();
    } else {
        $status = $error->httpStatusCode();
        $state = $status >= 400 && $status < 500 ? 'failed' : 'unknown';

        return ['state' => $state, 'source' => null, 'after_seconds' => null];
    }

    if ($wait === null || $wait < 0) {
        return ['state' => 'paused', 'source' => $source, 'after_seconds' => null];
    }

    return [
        'state' => 'retry',
        'source' => $source,
        'after_seconds' => max(1, $wait) + random_int(1, 3),
    ];
}

Небольшой случайный запас добавляется к паузе, а не заменяет её. Для Telegram функция проверяет код 429: ошибка параметров или прав бота не должна разрешать повтор только из-за наличия поля retry_after.

Теперь создайте send-with-decision.php рядом с этой функцией и bootstrap.php из руководства PHP. Bootstrap загружает Composer, проверяет конфигурацию и создаёт $bot с maxRetries: 0. Следующий фрагмент выполняет одну реальную отправку в настроенный тестовый чат:

PHP · send-with-decision.php
declare(strict_types=1);

use BotGate\Exception\BotGateException;

require __DIR__.'/bootstrap.php';
require __DIR__.'/delivery-decision.php';

try {
    $bot->call('sendMessage', [
        'chat_id' => $requiredEnv('TELEGRAM_CHAT_ID'),
        'text' => 'Тест обработки ответа',
    ]);
    $decision = ['state' => 'sent', 'source' => null, 'after_seconds' => null];
} catch (BotGateException $error) {
    $decision = telegramFailureDecision($error);
}

После запроса сохраните решение в своём журнале уведомлений или передайте его планировщику:

  • sent — зафиксируйте подтверждённый успех.
  • retry — сохраните время следующей попытки и обновите общий cooldown для источника source; повторяйте только в пределах бюджета попыток.
  • paused — есть лимит, но нет понятного времени ожидания; автоматический немедленный повтор запрещён этой политикой.
  • failed — требуется исправить запрос или настройки.
  • unknown — результат не установлен; не отправляйте сообщение заново без выбранной процедуры восстановления.

Функция не заменяет очередь, дедупликацию и учёт срока актуальности. При любом новом отказе нужно заново прочитать время ожидания, а не повторять по старому значению. Транспортные ошибки и 5xx намеренно не превращаются в разрешение на автоматическую отправку. Пример отложенного release() есть в статье для Laravel.

Источники и границы примера

Числа в примерах ответов вымышлены. Реальное время ожидания берите из конкретного ответа.