# Aiogram Errors

> Use when handling aiogram 3 API exceptions, error handlers, retries, or failed message edits.

- Skill: `ballisarium/aiogram-errors` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add ballisarium/aiogram-errors`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ballisarium/aiogram-errors/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: ballisarium (https://skillmd.com/u/ballisarium)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ballisarium/aiogram-errors

---


# aiogram errors

Every aiogram exception descends from `AiogramError`. Telegram's own failures arrive as
`TelegramAPIError` subclasses chosen by HTTP status and message.

```bash
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:

```text
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`.

```python
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):

```python
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:

```python
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:

```python
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

```python
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_after` honoured, never a hardcoded sleep
- `TelegramForbiddenError` deactivates rather than retries
- `TelegramMigrateToChat` persists the new chat id
- `@router.error` handlers ordered specific-first
- error handlers answer pending callback queries
- `TelegramBadRequest` substring checks isolated in helpers
- no bare `except Exception: pass` around API calls

## see also

- `aiogram-rate-limits` — `TelegramRetryAfter` in depth
- `aiogram-routing` — where `@router.error` sits
- `aiogram-filters` — `ExceptionTypeFilter` / `ExceptionMessageFilter`
- `skills/using-aiogram/references/telegram-quirks.md` — the behaviour behind these messages

