When scaling a Telegram bot, you will eventually encounter rate limits. Telegram enforces strict limits on how fast your bot can send messages. If you exceed these limits, the Bot API returns an HTTP 429 Too Many Requests status code.
This tutorial demonstrates how to handle HTTP 429 errors in PHP, extract the retry delay, and structure an outgoing message queue to prevent rate-limiting bottlenecks during broadcasts. We do not claim to build a fully distributed Redis-backed queue system here, but rather provide the core reference implementation for handling limits and retries.
Understanding Telegram's Rate Limits
Telegram enforces several limits on outgoing messages: * Global limit: Max 30 messages per second across all chats. * Single chat limit: Max 20 messages per minute (approx. 1 message every 3 seconds) to a specific user, group, or channel. * Broadcast limit: Max 30 messages per second when sending to multiple users.
When you exceed these thresholds, the API returns a JSON response containing "ok": false, "error_code": 429, and a parameters object with a retry_after integer specifying the number of seconds you must wait before retrying.
1. Implementing a Rate-Limit Aware HTTP Client
This PHP class wraps cURL to send requests to the Telegram Bot API. It checks the HTTP status code, validates the JSON response, and throws a custom exception containing the retry_after value when a 429 error occurs.
<?php
declare(strict_types=1);
class TelegramRateLimitException extends Exception
{
private int $retryAfter;
public function __construct(int $retryAfter, string $message = "Rate limit exceeded")
{
parent::__construct($message, 429);
$this->retryAfter = $retryAfter;
}
public function getRetryAfter(): int
{
return $this->retryAfter;
}
}
class TelegramClient
{
private string $token;
public function __construct()
{
$this->token = (string) getenv('TELEGRAM_BOT_TOKEN');
if ($this->token === '') {
throw new RuntimeException('TELEGRAM_BOT_TOKEN environment variable is not set.');
}
}
public function sendRequest(string $method, array $payload): array
{
$url = "https://api.telegram.org/bot{$this->token}/{$method}";
$jsonPayload = json_encode($payload);
if ($jsonPayload === false) {
throw new InvalidArgumentException('Failed to encode payload to JSON: ' . json_last_error_msg());
}
$ch = curl_init($url);
if ($ch === false) {
throw new RuntimeException('Failed to initialize cURL.');
}
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonPayload);
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);
$curlError = curl_error($ch);
curl_close($ch);
if ($response === false) {
throw new RuntimeException("cURL request failed: {$curlError}");
}
$data = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException('Failed to parse Telegram API response as JSON.');
}
if ($httpCode === 429) {
$retryAfter = $data['parameters']['retry_after'] ?? 1;
throw new TelegramRateLimitException((int) $retryAfter);
}
if (isset($data['ok']) && $data['ok'] === false) {
$description = $data['description'] ?? 'Unknown error';
throw new RuntimeException("Telegram API Error: {$description} (Code: {$httpCode})");
}
return $data;
}
}
2. Structuring an Outgoing Message Queue
To prevent hitting the 20 messages per minute limit for a single chat, you should avoid sending messages synchronously in response to webhooks or user actions. Instead, write messages to a database queue and process them with a background worker.
Below is a schema and a simplified worker loop that respects both the per-chat limit and the global 30 messages/second limit.
CREATE TABLE telegram_message_queue (
id INT AUTO_INCREMENT PRIMARY KEY,
chat_id BIGINT NOT NULL,
payload JSON NOT NULL,
status VARCHAR(20) DEFAULT 'pending', -- pending, sent, failed, rate_limited
retry_after_time DATETIME NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_status_retry (status, retry_after_time),
INDEX idx_chat_status (chat_id, status)
);
Here is the PHP worker script that processes this queue. It tracks the last sent time per chat_id to enforce the 3-second delay (20 messages/minute) and handles TelegramRateLimitException by pausing the queue or rescheduling the message.
<?php
declare(strict_types=1);
// Assuming PDO connection $pdo and TelegramClient $client are initialized
$client = new TelegramClient();
$lastSentToChat = []; // Tracks last send timestamp per chat_id
while (true) {
// Fetch pending messages that are not locked by a retry delay
$stmt = $pdo->prepare("
SELECT id, chat_id, payload
FROM telegram_message_queue
WHERE status = 'pending'
AND (retry_after_time IS NULL OR retry_after_time <= NOW())
ORDER BY id ASC
LIMIT 30
");
$stmt->execute();
$messages = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($messages)) {
sleep(1); // No messages, idle
continue;
}
$sentThisBatch = 0;
foreach ($messages as $msg) {
$chatId = (int) $msg['chat_id'];
$payload = json_decode($msg['payload'], true);
$now = microtime(true);
// Enforce the 3-second delay per chat to prevent hitting the 20 msg/min limit
if (isset($lastSentToChat[$chatId]) && ($now - $lastSentToChat[$chatId]) < 3.0) {
continue; // Skip this message for this iteration to let other chats process
}
try {
$client->sendRequest('sendMessage', $payload);
// Update status to sent
$update = $pdo->prepare("UPDATE telegram_message_queue SET status = 'sent' WHERE id = ?");
$update->execute([$msg['id']]);
$lastSentToChat[$chatId] = microtime(true);
$sentThisBatch++;
// Enforce global limit: max 30 messages per second
if ($sentThisBatch >= 30) {
sleep(1);
$sentThisBatch = 0;
}
} catch (TelegramRateLimitException $e) {
$retryAfter = $e->getRetryAfter();
// Reschedule this message and set status back to pending with a delay
$delayUntil = (new DateTime())->modify("+{$retryAfter} seconds")->format('Y-m-d H:i:s');
$update = $pdo->prepare("
UPDATE telegram_message_queue
SET status = 'pending', retry_after_time = ?
WHERE id = ?
");
$update->execute([$delayUntil, $msg['id']]);
// Pause the worker for the duration of the rate limit to avoid spamming the API
sleep($retryAfter);
break; // Break the foreach loop to fetch fresh data after the sleep
} catch (Exception $e) {
// Handle permanent failures (e.g., user blocked bot)
$update = $pdo->prepare("UPDATE telegram_message_queue SET status = 'failed' WHERE id = ?");
$update->execute([$msg['id']]);
}
}
// Clean up memory for inactive chats
if (count($lastSentToChat) > 1000) {
$lastSentToChat = array_slice($lastSentToChat, -500, null, true);
}
usleep(100000); // 100ms sleep between batches
}
Production Notes
* Grouping Messages: If your bot needs to send multiple updates to a single chat (e.g., a notification with text and several images), do not send them as separate messages. Group them using the sendMediaGroup method or concatenate the text into a single sendMessage payload. This reduces your request volume and prevents hitting the 20 msg/min limit. * Webhook Secret Token: When processing incoming updates via webhooks, always verify the X-Telegram-Bot-Api-Secret-Token header to ensure the request originates from Telegram before processing or queuing any actions. * Database Cleanup: In production, clean up or archive rows in telegram_message_queue with status = 'sent' or status = 'failed' regularly to keep index lookups fast.
For more details on the underlying API limits and webhook specifications, refer to the official documentation at https://botservice.biz/telegram-bot-api.
BotCreator — studio that ships Telegram bots / Mini Apps.