Обработка ограничений скорости API бота Telegram в PHP: 429, Retry-After, исходящие очереди и группировка

Что мы собираем

Bot API Telegram ограничивает частоту запросов по чату и глобально. При превышении вы получаете HTTP 429 Too Many Requests с retry_after в секундах — или тихие потери при рассылках. В этом уроке:

  • корректный разбор 429 и retry_after в PHP через cURL;
  • небольшая исходящая очередь с сериализацией отправок по чату;
  • буферизация всплесков в одном чате с учётом ~1 сообщения в секунду;
  • типичные поломки при массовых рассылках.

Это не обзор фреймворков, а рабочий PHP-паттерн рядом с обработчиком вебхука.

Лимиты, которые нужно соблюдать

Официальная документация — источник истины. На практике:

  • ~1 сообщение в секунду в один и тот же личный чат; ~20 в минуту в одну группу;
  • ~30 сообщений в секунду глобально на бота (короткие всплески допустимы);
  • sendMessage и соседние методы возвращают 429 с JSON-полем parameters.retry_after (секунды).

Игнорируйте лимиты — Telegram начнёт троттлить, очередь вырастет, рассылка «сломается». Считайте retry_after авторитетным: не выдумывайте свой backoff.

1. Обёртка cURL с Retry-After

Парсите JSON, проверяйте ok, на 429 читайте retry_after. Не засыпайте на фиксированное время вслепую.

<?php

function tg(string $method, array $params): array
{
$token = getenv('TG_BOT_TOKEN');
$ch = curl_init("https://api.telegram.org/bot{$token}/{$method}");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 15,
CURLOPT_POSTFIELDS => http_build_query($params),
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($body === false) {
$err = curl_error($ch);
curl_close($ch);
throw new RuntimeException("cURL error: {$err}");
}
curl_close($ch);

$decoded = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException('Bad JSON: ' . json_last_error_msg());
}

if ($status === 429 && is_array($decoded)) {
$retry = $decoded['parameters']['retry_after'] ?? 1;
$err = new RuntimeException("rate limited: retry after {$retry}s");
$err->retryAfter = (int) $retry;
throw $err;
}
if (empty($decoded['ok'])) {
throw new RuntimeException('TG error: ' . ($decoded['description'] ?? 'unknown'));
}
return $decoded['result'];
}

Токен — только из getenv('TG_BOT_TOKEN'). http_build_query предсказуемо кодирует параметры, в том числе при parse_mode=HTML.

2. Исходящая очередь по chat_id

Простейшая корректная очередь — FIFO на чат и цикл воркера. У каждого чата своя последовательность. При retry_after спите столько, сколько сказал Telegram; иначе ~1.1 с между отправками в один чат.

<?php

final class TgQueue
{
/** @var array<int, array<int, array{0:string,1:array}>> */
private array $byChat = [];
/** @var array<int, true> */
private array $inflight = [];

public function enqueue(int $chatId, string $method, array $params): void
{
$this->byChat[$chatId][] = [$method, $params];
}

public function run(int $maxIdleRounds = 5): void
{
$idle = 0;
$lastPerChat = [];

while (!empty($this->byChat) || !empty($this->inflight)) {
$progress = false;

foreach ($this->byChat as $chatId => $jobs) {
if (isset($this->inflight[$chatId])) {
continue;
}
$now = microtime(true);
if (isset($lastPerChat[$chatId]) && $now - $lastPerChat[$chatId] < 1.1) {
continue;
}

[$method, $params] = array_shift($jobs);
$this->byChat[$chatId] = $jobs;
$this->inflight[$chatId] = true;

try {
tg($method, $params);
} catch (RuntimeException $e) {
if (isset($e->retryAfter)) {
array_unshift($this->byChat[$chatId], [$method, $params]);
sleep(max(1, $e->retryAfter));
} else {
error_log("drop job {$method} chat {$chatId}: " . $e->getMessage());
}
}

$lastPerChat[$chatId] = microtime(true);
unset($this->inflight[$chatId]);
if (empty($this->byChat[$chatId])) {
unset($this->byChat[$chatId]);
}
$progress = true;
}

if (!$progress) {
$idle++;
if ($idle >= $maxIdleRounds) {
break;
}
usleep(200000);
} else {
$idle = 0;
}
// Глобальный потолок ~30/с. При 1.1 с на чат срабатывает редко;
// нагруженным ботам нужен token bucket.
}
}
}

Это намеренно не Redis. Для одного процесса на сотни сообщений в минуту достаточно. Для нескольких воркеров — списки Redis по chat_id и атомарный pop.

3. Группировка сообщений в одном чате

Если один чат порождает много мелких апдейтов, не вызывайте sendMessage на каждое событие. Лучше:

  • буферизовать текст и слать не чаще раза в секунду (помогает пауза 1.1 с);
  • для живого статуса — editMessageText одного сообщения вместо N новых.
<?php

final class ChatBuffer
{
private array $pending = []; // chatId => string

public function push(int $chatId, string $text): void
{
$this->pending[$chatId] = ($this->pending[$chatId] ?? '') . $text;
}

public function flush(TgQueue $q): void
{
foreach ($this->pending as $chatId => $text) {
$q->enqueue($chatId, 'sendMessage', [
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'HTML',
]);
}
$this->pending = [];
}
}

Сбрасывайте буфер по таймеру ~1 с. Для HTML экранируйте ввод: htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').

4. Что ломается в рассылках

  • Фанаут быстрее ~30 msg/s. Наивный цикл по подписчикам упирается в глобальный лимит. Гоните через очередь и token bucket около 25/s.
  • Одинаковый текст во много чатов сразу. Добавьте джиттер (random_int(0, 500) мс) перед enqueue.
  • Создание записи после отправки. Сгенерируйте id через bin2hex(random_bytes(7)), сделайте INSERT, затем поставьте уведомление в очередь.
  • Курсор update_id в памяти. Сохраняйте его. После рестарта повторная обработка удваивает исходящий трафик.
  • callback_data больше 64 байт. Кладите короткий id, не всю строку сущности.
  • Забытый answerCallbackQuery. Кнопка крутится и выглядит как rate limit, хотя дело не в лимитах.

5. Заметки для продакшена

  • Нижняя граница для retry_after: даже при 0 спите хотя бы секунду.
  • Для нескольких процессов забирайте задания через SELECT ... FOR UPDATE SKIP LOCKED (PostgreSQL) или колонку claimed_at (MySQL).
  • Circuit breaker: после ~20 подряд 429 — пауза воркера на 30 секунд.
  • Логируйте каждый 429 с retry_after, chat_id и методом.
  • Вебхук должен ответить Telegram за несколько секунд. Ставьте исходящие вызовы в очередь и сразу возвращайте 200 — не вызывайте sendMessage синхронно в вебхуке.

Итог

Соблюдайте Retry-After, держите темп на чат и выносите исходящие вызовы за очередь. Этого достаточно, чтобы бот переживал рассылки и нагрузку.

Если боты упираются в лимиты каждый день и нужны очереди, ретраи и pacing «из коробки», начните с botservice.biz.

Новые статьи — в Telegram

Разбираем, что автоматизировать в бизнесе и как это работает на практике. Без спама.