phpbotgram

Error model

Errors in phpbotgram split into two channels: typed Telegram API failures (the server said no) and dispatcher-synthetic error events (a handler raised). The framework typed both so you can catch selectively.

How it works

Telegram API exceptions

API failures are TelegramApiException subclasses. The session inspects the HTTP status and the response body's error_code / description fields, then throws the most specific subclass: TelegramBadRequestException for 400s, TelegramRetryAfter for 429 with the retry_after payload, TelegramConflictException for 409 (another getUpdates already running), TelegramForbiddenException for 403, TelegramNotFoundException for 404, and the server-side 5xx branch through TelegramServerException. TelegramNetworkException wraps transport-level failures (connection refused, TLS handshake errors). The hierarchy mirrors aiogram one-for-one so catch-by-type ports without surprise.

Catching a specific subclass in a handler is straightforward:

use Gruven\PhpBotGram\Exceptions\TelegramForbiddenException;
use Gruven\PhpBotGram\Types\Message;

$handler = static function (Message $event): void {
    try {
        $event->answer('Hello!')->emit();
    } catch (TelegramForbiddenException $e) {
        // bot was blocked by the user; log and move on
    }
};

Handler errors and the errors observer

Handler exceptions take a different path. ErrorsMiddleware, wired into the dispatcher automatically, catches any throw inside a handler and constructs an ErrorEvent holding the original Update plus the Throwable . It then re-enters propagateEvent('error', ...) so a registered error observer can claim the failure.

Unlike Telegram update events, ErrorEvent does not extend TelegramObject — it lives in Types/ only because aiogram puts it there, but it's a standalone readonly value object with no serializer / bot plumbing. Error handlers register on $dispatcher->errors (or $router->errors ) and follow the same filter/middleware contract as any other observer.

ExceptionTypeFilter matches by instanceof ; ExceptionMessageFilter matches by regex against getMessage() . Together they let an error observer chain handlers selectively — the example below fires only on TelegramRetryAfter , not on every error:

use Gruven\PhpBotGram\Bot;
use Gruven\PhpBotGram\Dispatcher\Dispatcher;
use Gruven\PhpBotGram\Dispatcher\PollingOptions;
use Gruven\PhpBotGram\Exceptions\TelegramRetryAfter;
use Gruven\PhpBotGram\Filters\ExceptionTypeFilter;
use Gruven\PhpBotGram\Types\ErrorEvent;

$bot = new Bot(getenv('BOT_TOKEN'));
$dispatcher = new Dispatcher();

$dispatcher->errors->register(
    static function (ErrorEvent $event): void {
        // Narrow for static analysis; the filter already guarantees the type.
        $exception = $event->exception;
        if ($exception instanceof TelegramRetryAfter) {
            fwrite(STDERR, "Flood wait {$exception->retryAfter}s on this bot.\n");
        }
    },
    filters: [new ExceptionTypeFilter(TelegramRetryAfter::class)],
);

$dispatcher->runPolling(new PollingOptions(), $bot);

The framework also ships a TokenValidationException for malformed tokens at bot construction time, distinct from the runtime API exceptions.

Control-flow exceptions

Two control-flow exceptions are internal signalling primitives, not errors. SkipHandlerException tells the dispatcher to skip the current handler and try the next one; CancelHandlerException tells it to abandon the dispatch entirely. Throwing either from a handler is the supported way to fall through or abort — ErrorsMiddleware recognises these by type and re-raises them without converting to an ErrorEvent . The naming follows aiogram's SkipHandler / CancelHandler exceptions verbatim.

use Gruven\PhpBotGram\Dispatcher\Event\CancelHandlerException;
use Gruven\PhpBotGram\Dispatcher\Event\SkipHandlerException;
use Gruven\PhpBotGram\Types\Message;

// Skip this handler; the dispatcher tries the next registered handler.
$skipHandler = static function (Message $event): void {
    if (($event->text ?? '') === '') {
        throw new SkipHandlerException();
    }
    $event->answer('Got text!')->emit();
};

// Abandon the entire dispatch for this update; no further handlers run.
$cancelHandler = static function (Message $event): void {
    if (($event->fromUser?->isBot ?? false)) {
        throw new CancelHandlerException();
    }
    $event->answer('Hello, human!')->emit();
};

Polling-loop failures

Polling-loop failures have their own contract. A RestartingTelegram or TelegramNetworkException in getUpdates routes through Backoff — exponential delay with jitter, configured per dispatcher via PollingOptions::$backoffConfig (note the field is $backoffConfig , not $backoff ).

TelegramRetryAfter is special-cased: the loop sleeps for the exact retryAfter seconds the API advertised, then retries without consulting the backoff. The distinction matters: backoff is for unknown network trouble, retry_after is an explicit flood-wait contract and growing the delay beyond the advertised value would just waste throughput.

Trade-offs

The typed hierarchy is wide. A general "log everything" error handler reads Throwable and looks at getMessage() ; a fine-grained handler that does retry-on-network catches TelegramNetworkException specifically. The wide hierarchy is a cost (more classes to know about) for a benefit (catch-by-type instead of inspecting an error-code string). Aiogram makes the same trade. The hierarchy matches Telegram's documented error-code grouping, so a handler that catches TelegramApiException and tests getErrorCode() is doing extra work the typed catch already does.

ErrorEvent deliberately does not extend TelegramObject . This saves the dispatcher from running the serializer on an error event (which would be meaningless — it has no wire form) and clarifies the type's role. The cost is that error handlers cannot use TelegramObject -typed methods on the value; the benefit is that the framework cannot accidentally try to send an error event back to Telegram.

SkipHandler and CancelHandler are exceptions, not return-values. PHP's typed return values would have made this awkward — mixed is the contract, and a sentinel RejectedSentinel already exists for "this handler didn't match". Using exceptions for the intended control flow is unusual but readable: throw new SkipHandler; is clearer than return RejectedSentinel::instance(); at the call site. The cost of exception-based control flow is one stack-unwind per throw, which is negligible against the rest of dispatch.

There is no built-in retry middleware. We could ship one ("retry TelegramRetryAfter N times before giving up") but policy varies enough that a default would be wrong for most deployments. The right place for a custom retry is a session middleware (it sees the outbound call) or a dispatcher middleware (it sees the inbound event); both seams are stable.

The silentCallRequest mechanism on the dispatcher swallows TelegramApiException from the late webhook-fallthrough path and emits a RuntimeWarning . The reasoning is that the request lifecycle is already over by the time the late call surfaces, and there's nowhere to surface the failure — except as a warning to logs. Bots that need to alert on this specific case should attach a logging handler that watches for E_USER_WARNING from the framework.

See also

Search results