Prevent Bot Crashes from Telegram 429 Rate Limits in Yii2 with a Custom TelegramClient Component
When building background notification systems or interactive services in Yii2, developers often resort to quick, raw cURL calls or generic third-party packages scattered throughout controllers and queue jobs. This approa
When building background notification systems or interactive services in Yii2, developers often resort to quick, raw cURL calls or generic third-party packages scattered throughout controllers and queue jobs. This approach works in local testing when traffic is low. However, as soon as your Yii2 application sends transactional messages (order confirmations, password reset codes, or broadcast notifications) to hundreds of users simultaneously, Telegram's API returns 429 Too Many Requests. Without structured retry mechanisms, these calls fail instantly, dropping critical messages and leaving database states out of sync.
Furthermore, sprinkling API tokens directly inside active record models or controllers leaks sensitive credentials into version control. If Telegram changes its HTTP status response or if your server experiences transient network timeouts, unhandled API errors will pollute your application logs without providing actionable context.
To fix these architectural vulnerabilities, you need a dedicated TelegramClient service component integrated into Yii2's Dependency Injection (DI) container. In this tutorial, we will construct a production-ready HTTP client tailored for the Telegram Bot API. We will implement automatic retries for rate-limited requests using Telegram's retry_after parameter, log structured API failures, and isolate API credentials securely using Yii2's local configuration parameters.
Moving Tokens to Local Configuration and Registering DI
Hardcoding Telegram bot tokens in config/params.php or inside class files is a major security liability. Version control leaks allow unauthorized parties to take over your bot, inspect incoming updates, or spam your users. Yii2 provides a built-in configuration merge mechanism that separates common default parameters from machine-specific or secret local parameters.
First, define the default parameter array in config/params.php. This file is committed to your repository and establishes key names without exposing actual credentials:
<?php
return [
'adminEmail' => '[email protected]',
'telegram' => [
'botToken' => null,
'apiUrl' => 'https://api.telegram.org/bot',
'timeout' => 10,
'maxRetries' => 3,
],
];
Next, create config/params-local.php on your application server. This file must be added to your .gitignore rules so secrets never reach remote repositories:
<?php
return [
'telegram' => [
'botToken' => '123456789:AAHgF_your_actual_bot_token_here',
],
];
Yii2's entry scripts automatically merge params-local.php over params.php. Now we configure the Dependency Injection container inside config/web.php (and config/console.php for queue workers or console tasks). Defining the component in the DI container ensures that any class requesting a TelegramClient instance receives a fully configured object without needing to fetch settings manually from global arrays.
<?php
$params = array_merge(
require __DIR__ . '/params.php',
require __DIR__ . '/params-local.php'
);
$config = [
'id' => 'basic',
'basePath' => dirname(__DIR__),
'bootstrap' => ['log'],
'components' => [
'telegram' => [
'class' => 'app\components\TelegramClient',
'botToken' => $params['telegram']['botToken'],
'apiUrl' => $params['telegram']['apiUrl'],
'timeout' => $params['telegram']['timeout'],
'maxRetries' => $params['telegram']['maxRetries'],
],
],
];
return $config;
By decoupling configuration from execution, you make your application easier to test. During unit or integration testing, you can easily mock the TelegramClient component or point it to a fake HTTP server by altering local params or DI container definitions.
Building the Resilient TelegramClient Service Component
A standard HTTP request to Telegram can fail in three distinct ways:
- Network transport failure (e.g., DNS resolution error, cURL timeout, connection reset).
- HTTP protocol errors or invalid payloads resulting in non-200 HTTP response codes.
- Logical Telegram API errors where the HTTP status might be 200 or 400, but the JSON payload contains
"ok": false.
Crucially, when your bot exceeds Telegram's rate limits (e.g., 30 messages per second globally or 1 message per second per chat), Telegram returns HTTP code 429 Too Many Requests. The response body contains a JSON object with "error_code": 429 and a parameters.retry_after field indicating the exact number of seconds you must wait before retrying.
If your code does not parse this field and pause execution, subsequent retry attempts will continue to fail, triggering longer cool-down penalties from Telegram.
Below is the complete implementation of app\components\TelegramClient. It inherits from yii\base\Component and uses native PHP cURL for precise transport control, avoiding unnecessary external dependencies while supporting robust retry loops and structured Yii2 logging.
<?php
namespace app\components;
use Yii;
use yii\base\Component;
use yii\base\InvalidConfigException;
class TelegramClient extends Component
{
public string $botToken = '';
public string $apiUrl = 'https://api.telegram.org/bot';
public int $timeout = 10;
public int $maxRetries = 3;
public function init(): void
{
parent::init();
if (empty($this->botToken)) {
throw new InvalidConfigException('Telegram botToken must be configured in params-local.php.');
}
}
/**
* Executes a call to the Telegram Bot API with automatic 429 retry handling.
*
* @param string $method Telegram API method (e.g., 'sendMessage')
* @param array $params Request parameters
* @return array Telegram API response payload ('result')
* @throws \Exception on non-recoverable API errors or exhausted retries
*/
public function sendRequest(string $method, array $params = []): array
{
$url = rtrim($this->apiUrl, '/') . $this->botToken . '/' . $method;
$attempt = 0;
while ($attempt <= $this->maxRetries) {
$attempt++;
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($params),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_HTTPHEADER => [
'Content-Type: application/x-www-form-urlencoded',
],
]);
$rawResponse = curl_exec($ch);
$curlErrno = curl_errno($ch);
$curlError = curl_error($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($curlErrno !== 0) {
Yii::error("Telegram cURL Error [{$method}]: {$curlError} (Attempt {$attempt}/{$this->maxRetries})", 'telegram');
if ($attempt > $this->maxRetries) {
throw new \Exception("Telegram network connection failed: {$curlError}");
}
sleep(1);
continue;
}
$response = json_decode($rawResponse, true);
if (json_last_error() !== JSON_ERROR_NONE) {
Yii::error("Telegram Invalid JSON Response [{$method}]: " . json_last_error_msg(), 'telegram');
throw new \Exception("Invalid response payload from Telegram API.");
}
if (isset($response['ok']) && $response['ok'] === true) {
return $response['result'] ?? [];
}
// Handle 429 Too Many Requests
$errorCode = $response['error_code'] ?? $httpCode;
if ($errorCode === 429) {
$retryAfter = $response['parameters']['retry_after'] ?? 1;
Yii::warning("Telegram Rate Limit Hit [{$method}]: Sleeping for {$retryAfter}s (Attempt {$attempt}/{$this->maxRetries})", 'telegram');
if ($attempt > $this->maxRetries) {
throw new \Exception("Telegram API rate limit exceeded. Retries exhausted.");
}
sleep((int)$retryAfter);
continue;
}
// Unrecoverable Telegram API Error (e.g. 400 Bad Request, 403 Forbidden)
$description = $response['description'] ?? 'Unknown API error';
Yii::error("Telegram API Error [{$method}]: [{$errorCode}] {$description}", 'telegram');
throw new \Exception("Telegram API request failed: [{$errorCode}] {$description}");
}
throw new \Exception("Telegram request failed after {$this->maxRetries} attempts.");
}
}
Let's review the mechanics of this implementation:
-
Initialization Check: The
init()method validates that$botTokenis set upon component instantiation. If someone forgets to populateparams-local.php, Yii2 throws anInvalidConfigExceptionimmediately during bootstrap rather than failing silently on execution. -
cURL Options: We set explicit network timeouts (
CURLOPT_TIMEOUTandCURLOPT_CONNECTTIMEOUT). Unbounded HTTP requests can stall PHP workers indefinitely, exhausting available web server processes during high-load events or cloud network hiccups. -
JSON Error Detection: Using
json_decode()without checkingjson_last_error()risks silently interpreting blank or corrupted responses as valid outputs. -
Rate Limit Cool-down: When a 429 status is detected, the method inspects
$response['parameters']['retry_after']. Instead of immediately hammering the API again,sleep((int)$retryAfter)pauses execution until Telegram's gateway is ready to accept requests again. -
Logging Category: Log messages specify
'telegram'as their target category, allowing you to route Telegram-specific warnings and errors to dedicated log files inconfig/web.php.
Consuming TelegramClient in Controllers and Queue Workers
Now that the component is fully configured and handles rate limits gracefully, let's look at how to consume it throughout your application. In Yii2, components registered under components in configuration can be accessed directly via Yii::$app->telegram or retrieved through service locators.
Here is an example controller action that sends an order dispatch notice to a customer, using HTML formatting and proper input escaping:
<?php
namespace app\controllers;
use Yii;
use yii\web\Controller;
use yii\web\Response;
use yii\helpers\Html;
class OrderController extends Controller
{
/**
* Sends an HTML-formatted dispatch confirmation to a buyer via Telegram.
*/
public function actionNotifyDispatch(int $orderId, string $telegramChatId): Response
{
$this->response->format = Response::FORMAT_JSON;
// Simulated order payload fetch
$trackingCode = 'TRK-982314-X';
// Always sanitize dynamic inputs when using parse_mode=HTML
$safeTrackingCode = Html::encode($trackingCode);
$safeOrderId = Html::encode((string)$orderId);
$messageText = "<b>Order #{$safeOrderId} Dispatched!</b>\n";
$messageText .= "Your shipment tracking code is: <code>{$safeTrackingCode}</code>";
try {
/** @var \app\components\TelegramClient $telegram */
$telegram = Yii::$app->get('telegram');
$result = $telegram->sendRequest('sendMessage', [
'chat_id' => $telegramChatId,
'text' => $messageText,
'parse_mode' => 'HTML',
'disable_web_page_preview' => true,
]);
return [
'success' => true,
'message_id' => $result['message_id'] ?? null,
];
} catch (\Exception $e) {
Yii::error("Failed to deliver order notification for Order #{$orderId}: " . $e->getMessage(), 'orders');
return [
'success' => false,
'error' => 'Notification delivery failed. Administrator has been notified.',
];
}
}
}
Notice the critical step when dealing with Telegram's parse_mode = 'HTML': any user-supplied or dynamic string inserted into the message (such as customer names, address fields, or tracking numbers) MUST be wrapped with Html::encode() or htmlspecialchars(). Unescaped characters like <, >, or & in tracking codes or user text will trigger a 400 Bad Request: can't parse entities error from Telegram's API, causing message dispatch to fail even if the API transport layer is functioning perfectly.
Production Notes
When running this component in high-volume production environments, consider the following operational rules:
Queue Workers vs. Synchronous Web Requests: Never run extensive retry loops synchronously during user web requests. If a rate limit triggers a 10-second sleep inside
actionNotifyDispatch(), the HTTP client (browser or mobile app) will sit hanging, and your web server process (FPM worker) will be blocked. Instead, offload notification delivery to a background queue processing system likeyii2-queue. Queue jobs can catch 429 exceptions and release the job back to the queue with a delay matchingretry_after.Sanitize Sensitive Data in Log Targets: By default,
Yii::error()writes exception stack traces and log categories toruntime/logs/app.log. Ensure your log configurations do not write raw bot tokens to disk if log aggregation tools (like Sentry or Elasticsearch) ingest those files. Our component constructs cURL URLs using$this->botToken, but exception messages omit the raw URL string to avoid leaking bot credentials in stack traces.Callback Query Response Mandates: If your bot receives inline button updates via webhooks, Telegram requires you to invoke
answerCallbackQuerywithin 10 seconds. If your processing logic hits a 429 rate limit or experiences network delay while attempting to complete an action, issue ananswerCallbackQuerycall early to stop the loading spinner on the user's interface before performing secondary background tasks.Connection Timeout Tuning: In high-concurrency environments, set
$timeoutto a realistic ceiling (e.g., 5 to 10 seconds). In persistent background workers, stalled cURL requests consume memory and delay processing queues. Setting explicit connection timeouts (CURLOPT_CONNECTTIMEOUT => 5) prevents hung DNS resolution or unreachable host gateways from locking workers indefinitely.
By abstracting API communication into a Yii2 component with automated 429 retry management, DI support, and environment parameter isolation, you eliminate random notification drops and protect bot credentials across deployments.
If you are expanding your Telegram integration into interactive web applications or automated processing workflows, check out BotCreator — studio that ships Telegram bots / Mini Apps.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.