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

Що ми збираємо

Telegram Bot API обмежує частоту запитів через чат і в усьому світі. Якщо ви перевищите його, ви отримаєте HTTP 429 Занадто багато запитів з retry_after в секундах — або тихі втрати під час розсилок. На цьому уроці:

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

Це не огляд фреймворків, а робочий шаблон PHP поруч із обробником вебхуків.

Обмеження, яких слід дотримуватися

Офіційна документація є джерелом істини. На практиці:

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

Ігноруйте ліміти — Telegram почне дроселювати, черга зростатиме, розсилка «зламається». Розглянемо retry_after авторитетний: не компенсуйте свій зворотній бік.

1. Обгортка cURL з повторною спробою після

Синтаксичний аналізатор JSON, перевірте ГараздНа 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 і атомний поп.

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_Replace, 'UTF-8').

4. Що порушує інформаційні бюлетені

  • Fanout працює швидше ~30 мсг/с. Наївний цикл абонентів спирається на глобальний ліміт. Проїжджайте через чергу та відро токенів близько 25/с.
  • Один і той самий текст у багатьох чатах одночасно. Додати джиттер.random_int(0,500) мс) перед чергою.
  • Створіть запис після завантаження. Згенерувати ідентифікатор у bin2hex(random_bytes(7)), зробіть вставку, а потім поставте сповіщення в чергу.
  • Курсор update_id в пам 'яті. Збережіть його. Після перезапуску повторна обробка подвоює вихідний трафік.
  • callback_data більше 64 байт. Введіть короткий ідентифікатор, а не весь рядок сутності.
  • Забули answerCallbackQuery. Кнопка обертається і виглядає як ліміт швидкості, хоча йдеться не про ліміти.

5. Виробничі примітки

  • Нижнє поле retry_after: навіть з 0 спати принаймні секунду.
  • Для декількох процесів підбирайте завдання за допомогою ВИБРАТИ ... ДЛЯ ОНОВЛЕННЯ ПРОПУСТИТИ ЗАБЛОКОВАНО (PostgreSQL) або стовпець claim_at (MySQL).
  • Автоматичний вимикач: через ~20 послідовно 429 — Робітник призупиняється на 30 секунд.
  • Увійдіть у кожну 429 з retry_after, chat_id і метод.
  • Вебхук повинен відповісти на Telegram за кілька секунд. Розмістіть вихідні дзвінки в черзі та негайно поверніть їх 200 — не дзвонити sendMessage синхронно в веб-гачку.

Підсумок

Спостереження Повторити спробу - після, тримайте темп для чату та приймайте вихідні дзвінки в чергу. Цього достатньо, щоб бот вижив у списках розсилки та робочому навантаженні.

Якщо боти щодня стикаються з обмеженнями і їм потрібні черги, повторні виклики та нестандартна стимуляція, почніть з botservice.biz.

Нові статті — у Telegram

Розбираємо, що автоматизувати в бізнесі та як це працює на практиці. Без спаму.