aiogram errors
Every aiogram exception descends from AiogramError. Telegram's own failures arrive as
TelegramAPIError subclasses chosen by HTTP status and message.
python skills/using-aiogram/tools/get-exception.py --tree
python skills/using-aiogram/tools/get-exception.py TelegramRetryAfter
when to use this skill
- deciding which exception to catch around an API call
- writing a global error handler
- a bot that dies on one bad chat
- reading an error string Telegram returned
- distinguishing a permanent failure from one worth retrying
the hierarchy
Verified on aiogram 3.31.0:
AiogramError
├── CallbackAnswerException
├── ClientDecodeError
├── DetailedAiogramError
│ ├── DataNotDictLikeError
│ ├── UnsupportedKeywordArgument
│ └── TelegramAPIError
│ ├── TelegramBadRequest
│ ├── TelegramConflictError
│ ├── TelegramForbiddenError
│ ├── TelegramMigrateToChat
│ ├── TelegramNetworkError
│ │ └── TelegramEntityTooLarge
│ ├── TelegramNotFound
│ ├── TelegramRetryAfter
│ ├── TelegramServerError
│ │ └── RestartingTelegram
│ └── TelegramUnauthorizedError
└── SceneException
TelegramAPIError carries the method that failed and the message Telegram returned.
Catch the narrowest class that matches the recovery you intend. except TelegramAPIError
around everything hides the difference between "retry in 30 seconds" and "this user
blocked you forever".
what each one means
| Exception | Meaning | Recovery |
|---|---|---|
TelegramBadRequest |
Telegram rejected the request | read the message — this class covers dozens of distinct causes |
TelegramForbiddenError |
user blocked the bot, or bot removed from the chat | permanent: mark inactive, stop sending |
TelegramNotFound |
chat, message, or user does not exist | permanent for that target |
TelegramUnauthorizedError |
token invalid or revoked | fatal: fix configuration |
TelegramConflictError |
another poller, or a webhook is set | run exactly one consumer |
TelegramRetryAfter |
flood limit | sleep error.retry_after, then retry |
TelegramMigrateToChat |
group became a supergroup | re-send to error.migrate_to_chat_id, persist the new id |
TelegramEntityTooLarge |
upload exceeds Telegram's size limit | do not retry; shrink or refuse |
TelegramNetworkError |
transport failure | transient: retry with backoff |
TelegramServerError / RestartingTelegram |
5xx from Telegram | transient: retry with backoff |
ClientDecodeError |
response was not the JSON aiogram expected | usually a proxy or a local Bot API server issue |
UnsupportedKeywordArgument |
unsupported registration keyword (often a v2 handler filter argument) | a bug in your code |
CallbackAnswerException |
misuse of the CallbackAnswer utility |
a bug in your code |
TelegramRetryAfter.retry_after and TelegramMigrateToChat.migrate_to_chat_id are the
two attributes worth remembering — they carry the information needed to recover.
error handlers
@router.error(...) receives an ErrorEvent with exactly two fields: update and
exception.
import logging
from aiogram import Router
from aiogram.exceptions import TelegramForbiddenError, TelegramRetryAfter
from aiogram.filters import ExceptionTypeFilter
from aiogram.types import ErrorEvent, User
router = Router(name="errors")
logger = logging.getLogger(__name__)
@router.error(ExceptionTypeFilter(TelegramForbiddenError))
async def blocked(event: ErrorEvent, event_from_user: User | None = None) -> None:
"""Permanent for this user. Deactivate rather than retry.
Error handlers receive workflow data, so `event_from_user` is injected the same
way it is for ordinary handlers. There is no `Update.event_from_user` attribute.
"""
logger.info("blocked by user %s", event_from_user.id if event_from_user else "unknown")
@router.error(ExceptionTypeFilter(TelegramRetryAfter))
async def flooded(event: ErrorEvent) -> None:
exception = event.exception
if isinstance(exception, TelegramRetryAfter):
logger.warning("flood limit, retry after %s s", exception.retry_after)
@router.error()
async def unhandled(event: ErrorEvent) -> None:
"""Register last: this matches everything."""
logger.exception(
"unhandled error on update %s",
event.update.update_id,
exc_info=event.exception,
)
- Error handlers are matched in registration order, like any other observer. Specific first, catch-all last.
- An error handler that itself raises produces an unhandled exception. Keep them dull.
- Handling the error swallows it — the update is considered done. Re-raise if you want aiogram's default logging as well.
Use a local catch when the handler can recover; use a router error handler for shared reporting. Log the original exception before attempting a user-facing apology. That API call may also fail (blocked user, expired callback, network failure):
from aiogram import Router
from aiogram.types import ErrorEvent
router = Router(name="user-facing-errors")
@router.error()
async def apologise(event: ErrorEvent) -> None:
"""Answer the pending callback query too, or the client spins forever."""
update = event.update
if update.callback_query is not None:
await update.callback_query.answer("Something went wrong.", show_alert=True)
elif update.message is not None:
await update.message.answer("Something went wrong. Please try again.")
reading TelegramBadRequest
TelegramBadRequest is a bucket. The message decides what happened:
| Message fragment | Cause | Fix |
|---|---|---|
message is not modified |
edited to identical text and markup | compare first, or catch it |
query is too old |
callback answered late or never | answer immediately |
message to edit not found |
message deleted, or wrong id | send a new one |
message can't be deleted |
outside Telegram's delete rules | check permissions |
chat not found |
wrong chat id, or bot never met the user | users must start the bot first |
can't parse entities |
malformed HTML/Markdown | escape, or use entities |
BUTTON_DATA_INVALID |
callback_data over 64 bytes |
shorten the payload |
file is too big |
over the 20 MB download limit | use a local Bot API server |
not enough rights |
missing admin permission | grant it, or degrade gracefully |
Matching on substrings is fragile but is the only option Telegram gives. Isolate it in one
helper rather than scattering in str(error) across the codebase:
from aiogram.exceptions import TelegramBadRequest
def is_not_modified(error: TelegramBadRequest) -> bool:
return "message is not modified" in str(error)
def is_stale_callback(error: TelegramBadRequest) -> bool:
return "query is too old" in str(error)
retry policy
Split failures into three buckets and treat each once:
import asyncio
from aiogram import Bot
from aiogram.exceptions import (
TelegramEntityTooLarge,
TelegramForbiddenError,
TelegramNetworkError,
TelegramNotFound,
TelegramRetryAfter,
TelegramServerError,
)
async def send_reliably(bot: Bot, chat_id: int, text: str, attempts: int = 3) -> bool:
"""True on API success, False for an unavailable target; exhaustion raises.
Network/5xx retry can duplicate a send already accepted by Telegram.
Use this policy only when the application accepts that tradeoff.
"""
if attempts < 1:
raise ValueError("attempts must be positive")
delay = 1.0
for attempt in range(attempts):
try:
await bot.send_message(chat_id=chat_id, text=text, parse_mode=None)
return True
except TelegramEntityTooLarge:
raise # subclass of TelegramNetworkError, but not transient
except TelegramRetryAfter as error:
if attempt == attempts - 1:
raise
await asyncio.sleep(error.retry_after)
except (TelegramNetworkError, TelegramServerError):
if attempt == attempts - 1:
raise
await asyncio.sleep(delay)
delay *= 2
except (TelegramForbiddenError, TelegramNotFound):
return False
raise AssertionError("unreachable")
aiogram.utils.backoff.Backoff / BackoffConfig implement the exponential-with-jitter
policy aiogram uses for polling, and can be reused instead of the manual doubling above.
do not swallow
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest
async def bad(bot: Bot, chat_id: int) -> None:
try:
await bot.send_message(chat_id=chat_id, text="hi")
except Exception: # noqa: BLE001 - shown as an anti-pattern
pass # WRONG: hides blocked users, bad tokens, and your own bugs
async def good(bot: Bot, chat_id: int, message_id: int) -> bool:
try:
await bot.edit_message_text(chat_id=chat_id, message_id=message_id, text="hi")
except TelegramBadRequest as error:
if "message is not modified" in str(error):
return False
raise
return True
A bare except Exception: pass around an API call is the single most common way a bot
ends up silently doing nothing. Catch a named class, and make the failure visible.
Equally: repairing the visible response while leaving state or data inconsistent turns one bug into two. If an operation half-completed, roll it back or record it.
checklist
- narrowest exception class caught for each recovery path
retry_afterhonoured, never a hardcoded sleepTelegramForbiddenErrordeactivates rather than retriesTelegramMigrateToChatpersists the new chat id@router.errorhandlers ordered specific-first- error handlers answer pending callback queries
TelegramBadRequestsubstring checks isolated in helpers- no bare
except Exception: passaround API calls
see also
aiogram-rate-limits—TelegramRetryAfterin depthaiogram-routing— where@router.errorsitsaiogram-filters—ExceptionTypeFilter/ExceptionMessageFilterskills/using-aiogram/references/telegram-quirks.md— the behaviour behind these messages