Любой публичный бот рано или поздно сталкивается с флудом: один юзер шлёт 100 команд в секунду, скрипт-кидди дёргает API, конкурент запускает DDoS. Без защиты бот падает в 429 от Telegram, очередь забивается, легитимные юзеры не дожидаются ответа. Разберём набор anti-flood паттернов, которые внедряются за пару часов и решают проблему на годы.
Уровни защиты
Защита строится в три слоя:
- На уровне Telegram — лимит 30 мсг/сек суммарно и 1 мсг/сек в чат, нельзя обойти.
- На уровне сети — nginx rate-limit на webhook.
- На уровне приложения — per-user, per-action, exponential backoff.
Каждый слой ловит свой класс атак.
Лимит входящих апдейтов
В nginx — token bucket по IP:
limit_req_zone $binary_remote_addr zone=tg_in:10m rate=200r/s;
location /tg/webhook {
limit_req zone=tg_in burst=400 nodelay;
proxy_pass http://bot:8080;
}
Telegram присылает с фиксированных IP, реальный лимит 200 r/s — заведомо больше любых ожидаемых нагрузок. Если упёрлись — значит DDoS на webhook, добавляем CDN.
Per-user rate-limit в приложении
Самый частый случай — один юзер шлёт 50 сообщений за 5 секунд. Лимитим через token bucket в Redis:
import time
import redis.asyncio as redis
async def allow_action(user_id: int, action: str, rate: float = 1.0, burst: int = 5) -> bool:
"""Token bucket: rate токенов в секунду, burst — максимум."""
key = f"rl:{user_id}:{action}"
now = time.time()
pipe = r.pipeline()
pipe.hgetall(key)
pipe.expire(key, 300)
state, _ = await pipe.execute()
last = float(state.get("last", now))
tokens = float(state.get("tokens", burst))
tokens = min(burst, tokens + (now - last) * rate)
if tokens < 1:
await r.hset(key, mapping={"last": now, "tokens": tokens})
return False
await r.hset(key, mapping={"last": now, "tokens": tokens - 1})
return True
Использование:
@router.message()
async def on_message(message: Message):
if not await allow_action(message.from_user.id, "msg", rate=1.0, burst=5):
return # тихо игнорируем
await handle(message)
5 сообщений быстро (burst), потом 1 в секунду. Превышение — игнор, без ответа (чтобы не давать сигнал «бот видит»).
Per-action лимиты
Разные действия — разные лимиты:
| Действие | Rate | Burst |
|---|---|---|
/start | 1/мин | 2 |
| Обычное сообщение | 1/сек | 5 |
| Команда поиска | 1/3 сек | 3 |
| Запрос платежа | 1/мин | 1 |
| Загрузка файла | 1/10 сек | 2 |
Команды, которые тратят ресурсы (LLM, генерация), лимитим жёстче.
Обработка 429 от Telegram
Если бот всё-таки превысил лимит на отправку, Telegram возвращает 429 с заголовком Retry-After. aiogram бросает TelegramRetryAfter:
from aiogram.exceptions import TelegramRetryAfter, TelegramAPIError
import asyncio
async def safe_send(bot, chat_id, text, **kwargs):
for attempt in range(5):
try:
return await bot.send_message(chat_id, text, **kwargs)
except TelegramRetryAfter as e:
await asyncio.sleep(e.retry_after + 0.5)
except TelegramAPIError as e:
if "Too Many Requests" in str(e):
await asyncio.sleep(2 ** attempt)
else:
raise
raise RuntimeError("send failed after 5 attempts")
Уважайте retry_after — это сам Telegram сказал «жди столько-то секунд». Игнорировать = бан на час.
Очередь отправки
При массовых рассылках — отдельный воркер с пейсингом:
from asyncio import Queue, sleep
send_queue: Queue = Queue()
async def sender(bot):
while True:
chat_id, text, kwargs = await send_queue.get()
try:
await safe_send(bot, chat_id, text, **kwargs)
finally:
await sleep(1 / 25) # 25 сообщений в секунду — запас от лимита 30
Один воркер на бота, всё через него. Гарантия: лимит Bot API не превысится никогда.
Теневой бан злостных нарушителей
Если юзер за минуту получил 50 отказов rate-limit, это либо скрипт, либо поломанный клиент. Теневой бан:
async def check_abuse(user_id: int):
rejects = await r.incr(f"reject:{user_id}")
await r.expire(f"reject:{user_id}", 3600)
if rejects > 50:
await r.set(f"shadow_ban:{user_id}", 1, ex=86400 * 7)
@router.message()
async def gate(message: Message):
if await r.exists(f"shadow_ban:{message.from_user.id}"):
return # молча игнорируем сутки
...
Юзер видит, что бот не отвечает, но не получает явной блокировки — не понимает, что забанен, и не пытается обходить.
Защита от фишинговых атак
Боты-конкуренты могут массово кликать на inline-кнопки или callback'и:
@router.callback_query()
async def cb_gate(cb: CallbackQuery):
if not await allow_action(cb.from_user.id, "cb", rate=2.0, burst=10):
await cb.answer() # пустой ack, чтобы UI не висел
return
...
Особенно важно для дорогих callback'ов, которые лезут в БД или внешние API.
Honeypot для скриптов
Скрипты часто шлют команды наугад. Можно поймать их «приманкой»:
@router.message(F.text.in_({"/admin", "/shell", "/eval", "/sql"}))
async def honeypot(message: Message):
await r.set(f"shadow_ban:{message.from_user.id}", 1, ex=86400 * 30)
await message.answer("Команда не найдена")
Любой реальный юзер не отправит /eval — это автоматически боты, и можно банить на месяц.
Метрики
| Метрика | Что показывает |
|---|---|
tg_429_total | сколько раз словили лимит Telegram |
rl_reject_total | отказов rate-limit per user |
shadow_ban_active | сколько в теневом бане |
queue_depth | глубина send-очереди |
latency_p99 | задержка между received и replied |
Дашборд в Grafana, алерты в админ-чат при tg_429_total > 10/мин или queue_depth > 1000.
Топ-5 ошибок
- Лимитят по
chat_idвместоuser_id— в группе один спамер блокирует чат. - Возвращают «Превышен лимит» — даёт сигнал, что бот видит, и провоцирует обход.
- Не уважают
retry_after— попадают в Telegram-бан на час. - Один общий лимит на все действия —
/startблокируется тем же лимитом, что callback. - Не мониторят 429 — узнают о проблеме от жалоб пользователей.
Итого
Anti-flood — это набор простых паттернов: token bucket per-user-per-action, очередь отправки с пейсингом, обработка 429 с уважением retry_after, теневой бан злостных и honeypot для скриптов. Реализуется за 1–2 дня и спасает бот от 99% атак. Не делайте этого до прода — после первого вирусного поста бот ляжет, и вы будете чинить вживую.
Частые вопросы
Какой rate-limit ставить по умолчанию?
1 сообщение в секунду с burst 5 — для большинства ботов нормально. Юзер пишет вопрос, ждёт ответа, пишет следующий — комфортно. Burst 5 покрывает «быстро отвечает на серию вопросов». Для callback'ов можно либеральнее: 2/сек с burst 10.
Что делать с 429 от Bot API?
Уважать retry_after точно до секунды, повторять отправку. Если получили 429 трижды подряд — приостановите весь поток отправки на 30 секунд и алерт в мониторинг. Если 429 происходит регулярно — у вас архитектурная проблема, нужна очередь с пейсингом, не реактивная обработка.
Можно ли блокировать по IP?
Webhook принимает только от Telegram-IP, сами юзеры до webhook не доходят. Поэтому per-IP блок на уровне приложения бесполезен — всё прилетает с одних и тех же IP. Лимиты ставьте по user_id и chat_id. На webhook — по IP в nginx, но это для DDoS-защиты, не для контроля за пользователями.
Как обнаружить координированную атаку нескольких аккаунтов?
Складывайте сигналы: schwellrate-limit reject'ов растёт у 50+ юзеров одновременно, все аккаунты младше 7 дней, без аватара, у всех одинаковая последовательность команд. Скрипты палятся на повторяемости. Ставьте автоматические правила «новые аккаунты с теми же паттернами → теневой бан».
Что важнее: rate-limit или защита бизнес-логики?
Оба, но в разных слоях. Rate-limit ловит флуд, защита логики ловит абуз (например, попытка применить промокод 100 раз). Без rate-limit бот ляжет под флудом раньше, чем сработает бизнес-валидация. Без бизнес-валидации флудеру удастся в рамках лимита нанести ущерб (например, выкрутить 100 промокодов с burst).
Как тестировать anti-flood?
locust или k6 с параметризацией по user_id. Скрипт генерирует тысячу аккаунтов и шлёт каждым по 50 запросов. Метрики: % успешных, % отказов rate-limit, latency p99 у легитимных юзеров (которые шлют 1 сообщение/мин). Норма: легитимные не страдают, флудеры получают отказы или теневой бан.
Сохранять ли rate-limit между рестартами?
Да, в Redis. Если в памяти процесса — каждый рестарт обнуляет защиту, и флудер пользуется этим. Redis с TTL на ключе автоматически очищается, оверхед минимален: пара сотен байт на юзера, при 1 млн активных — ~200 МБ.