Advanced

Production: queues and retries

Setting up a message queue and handling 429 (Too Many Requests) errors in the Telegram Bot API using PHP. Creating a reliable worker for production.

When a bot enters the active production stage (Production), you will inevitably face Telegram Bot API limits on sending messages (Rate Limits), as well as the need to handle temporary network and Telegram server failures.

If your bot sends broadcasts or serves thousands of users simultaneously, sending messages directly inside the webhook will cause PHP processes to hang, exceed rate limits, and lose updates. The solution to this problem is an architecture utilizing message queues and retry mechanisms.

Telegram Bot API Limits (Rate Limits)

Telegram strictly limits the frequency of API requests. The main limits to consider are:

  • To a single chat (private or group): no more than 1 message per second.
  • Groups and supergroups: no more than 20 messages per minute.
  • Overall bot limit: no more than 30 messages per second across all requests.

When these limits are exceeded, Telegram returns HTTP status code 429 Too Many Requests and a JSON response describing the error, containing the parameters.retry_after field. This is the number of seconds to wait before repeating the request.

{
"ok": false,
"description": "Too Many Requests: retry after 9",
"error_code": 429,
"parameters": {
"retry_after": 9
}
}

Why a Webhook Should Not Send Messages

When Telegram sends an update to your webhook, your script must execute as quickly as possible (preferably under 100 milliseconds) and return HTTP response 200 OK. If your script starts making heavy cURL requests, sending media files, or waiting out timeouts on 429 errors inside the webhook, Telegram will consider your server unavailable, drop the connection, and start resending the exact same update repeatedly.

A proper webhook architecture in Production looks like this:

  1. Receive the incoming JSON request from Telegram.
  2. Quickly validate it (token, IP).
  3. Write the update to a queue (database, Redis, RabbitMQ) or simply log the sending task.
  4. Return HTTP response 200 OK.
  5. A background process (Worker) picks tasks from the queue and sends them to Telegram while handling rate limits.

A Simple Message Queue using MySQL

For small and medium projects, it is not necessary to immediately deploy complex message brokers like RabbitMQ. A database table acting as a message buffer is quite sufficient.

Let's create the bot_messages_queue table to store outgoing messages:

CREATE TABLE `bot_messages_queue` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`chat_id` BIGINT NOT NULL,
`text` TEXT NOT NULL,
`status` VARCHAR(20) DEFAULT 'pending', -- pending, sent, failed
`retry_count` INT DEFAULT 0,
`send_after` DATETIME NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Implementing a Background Worker

The background script is launched via cron (e.g., every minute) or runs as a daemon (systemd service) and processes records from the queue table.

To send requests, we will use a reliable HTTP client. For details on the structure of sending requests via cURL, check out chapter HTTP-клиент на PHP.

Below is an example of a worker script in PHP that selects messages from the database, sends them, and gracefully handles the 429 Too Many Requests error:

<?php

$db = new PDO('mysql:host=localhost;dbname=bot_db;charset=utf8mb4', 'db_user', 'db_pass', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
]);

$token = getenv('TELEGRAM_BOT_TOKEN');
if (!$token) {
die("TELEGRAM_BOT_TOKEN is not set\n");
}

// Выбираем сообщения, готовые к отправке
$stmt = $db->prepare("SELECT * FROM bot_messages_queue
WHERE status = 'pending'
AND (send_after IS NULL OR send_after <= NOW())
AND retry_count < 5
ORDER BY id ASC
LIMIT 10");
$stmt->execute();
$messages = $stmt->fetchAll();

foreach ($messages as $msg) {
$payload = [
'chat_id' => $msg['chat_id'],
'text' => $msg['text'],
'parse_mode' => 'HTML'
];

$ch = curl_init("https://api.telegram.org/bot{$token}/sendMessage");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);

$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($response === false) {
// Ошибка сети — откладываем отправку на 30 секунд
delayMessage($db, $msg['id'], $msg['retry_count'] + 1, 30);
continue;
}

$result = json_decode($response, true);

if ($httpCode === 200 && isset($result['ok']) && $result['ok'] === true) {
// Успешно отправлено
$updateStmt = $db->prepare("UPDATE bot_messages_queue SET status = 'sent' WHERE id = ?");
$updateStmt->execute([$msg['id']]);

// Соблюдаем лимит в 1 сообщение в секунду на один чат
usleep(1000000);
} elseif ($httpCode === 429) {
// Превышен лимит запросов. Получаем время ожидания
$retryAfter = $result['parameters']['retry_after'] ?? 5;

// Откладываем отправку сообщения на указанное время
delayMessage($db, $msg['id'], $msg['retry_count'] + 1, $retryAfter);

// Приостанавливаем работу воркера на время блокировки
sleep($retryAfter);
} else {
// Другая ошибка (например, чат заблокирован пользователем)
$updateStmt = $db->prepare("UPDATE bot_messages_queue SET status = 'failed' WHERE id = ?");
$updateStmt->execute([$msg['id']]);
}
}

function delayMessage(PDO $db, int $id, int $newRetryCount, int $secondsDelay): void {
$sendAfter = date('Y-m-d H:i:s', time() + $secondsDelay);
$stmt = $db->prepare("UPDATE bot_messages_queue
SET retry_count = ?, send_after = ?, status = 'pending'
WHERE id = ?");
$stmt->execute([$newRetryCount, $sendAfter, $id]);
}

Production Tips

  • Error logging: Always log API responses that returned ok: false. This will help detect in time that users are blocking the bot or that you are using invalid HTML/Markdown markup.
  • Idempotency: During retries caused by network failures, there is a risk of sending the same message twice. If preventing duplicates is critical, use unique transaction identifiers on your database side.
  • Queue monitoring: Set up alerts for the queue table size. If the number of records with the pending status surges dramatically, it means your bot hit rate limits or the worker crashed.

Next: Заявка с сайта → Telegram