Дизайн rate limiting
Алгоритмы (token bucket, sliding window), хранилище, ключи, ответы 429 и реализация на проде.
Спроектируй rate limiting для {{service}}. Масштаб: {{scale}}.
1. Что защищаем и от чего
Прежде чем выбирать алгоритм — назови угрозы:
- Abuse: brute force, credential stuffing, scraping
- Fairness: один тяжёлый клиент не должен забирать ресурс у всех
- Cost: внешний LLM/API на $/токен, нужен жёсткий потолок
- Stability: защита backend от перегрузки upstream-сервиса
Разные угрозы — разные лимиты. Скан и API один лимит не покроют.
2. Алгоритмы и trade-offs
Fixed window
Счётчик: "X запросов в текущей минуте".
- Плюс: тривиально (один INCR в Redis)
- Минус: всплеск на границе окна — за 2 секунды можно сделать 2x лимит (конец минуты + начало следующей)
- Когда: грубая защита, не точная
Sliding window log
Хранит timestamp каждого запроса, считает попавшие в окно.
- Плюс: точно
- Минус: память O(N) на ключ
- Когда: малый объём, дорогие операции
Sliding window counter
Аппроксимация: текущее окно + взвешенный остаток предыдущего.
- Плюс: точность близка к log, память O(1)
- Минус: чуть сложнее реализация
- Когда: общий случай для HTTP API
Token bucket
Bucket ёмкостью burst, пополняется со скоростью rate/sec. Запрос берёт 1 токен.
- Плюс: позволяет короткие burst'ы выше среднего rate
- Минус: чуть больше state
- Когда: API где burst — нормальное поведение (загрузка, batch)
Leaky bucket
Очередь фиксированной длины, дренируется с постоянной скоростью.
- Плюс: сглаживает поток к downstream
- Минус: добавляет latency (буферизация)
- Когда: защита медленного downstream от всплесков
Дефолт для HTTP API: sliding window counter или token bucket. Берёшь burst-friendly если нагрузка пиковая, sliding если важна предсказуемость для downstream.
3. Хранилище состояния
Single instance — in-memory (Map с TTL, бесплатно).
Multi-instance — нужен distributed store:
- Redis — стандарт.
INCR+EXPIREили Lua-скрипт для atomic check-and-increment - Memcached — есть
INCR, но нет atomic SET-if-not-exists с TTL → менее удобно - DynamoDB / встроенные: только если уже в стеке, иначе overkill
Lua-скрипт для sliding window в Redis:
-- KEYS[1] = bucket key
-- ARGV[1] = window seconds, ARGV[2] = limit, ARGV[3] = now (ms)
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[3] - ARGV[1] * 1000)
local count = redis.call('ZCARD', KEYS[1])
if count < tonumber(ARGV[2]) then
redis.call('ZADD', KEYS[1], ARGV[3], ARGV[3])
redis.call('EXPIRE', KEYS[1], ARGV[1])
return {1, ARGV[2] - count - 1}
end
return {0, 0}
Atomic, без race между check и increment.
4. Ключ лимита (кого считаем)
От грубого к точному:
- IP — простой, но прокси/NAT/мобильные сети дают false positive
- User ID (если авторизован) — самый честный
- API key — для машин
- Tenant / организация — для shared плана
- Combination —
tenant + user + endpointдля fine-grained
Анонимные эндпойнты (signup, login) — IP + endpoint, плюс CAPTCHA после X неудач.
5. Уровни лимитов
Несколько слоёв одновременно:
- Global: общий потолок на инстанс/cluster — защита backend
- Per tenant: SLA-based (free: 100/min, pro: 10k/min)
- Per user: защита от единичного злоупотребления внутри tenant
- Per endpoint: дорогие операции (
POST /search) жёстче чем дешёвые (GET /me)
Принимаешь min из всех применимых.
6. Ответ 429
Что отдавать:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 30
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 1700000000
{ "error": "rate_limited", "retry_after_seconds": 30 }
Retry-After— обязательно. Клиенты на нём строят backoffRateLimit-*заголовки — IETF draft, помогают клиенту планировать- Никогда не 200 с "limited" в теле — ломаешь semantics
Возвращай 429 и на разрешённые запросы тоже отправляй RateLimit-Remaining — клиенты видят, когда близко к пределу, и саморегулируются.
7. Graceful degradation
Если Redis упал — не паникуй:
- Fail open: пропускать запросы (риск abuse, но сервис жив)
- Fail closed: блокировать (безопасно, но downtime)
Выбор зависит от угрозы. Для billing API — closed, для публичного content API — open. Логируй и алёрти на режим.
Локальный fallback: token bucket в памяти инстанса с консервативным лимитом — лучше чем ничего.
8. Distributed ловушки
- Часы инстансов — sliding window зависит от now(). Используй Redis TIME, не local clock
- Network round-trip в Redis на каждый запрос — добавь local cache на 100-500ms (особенно для разрешённых)
- Hot keys — если все запросы идут на один tenant, его ключ становится горячим. Шарди
tenant:<id>:<shard>где shard = hash(req_id) % N
9. Whitelist / bypass
- Internal services — bypass header с подписью (HMAC)
- Health checks — exclude path
- Admin / on-call — отдельные ключи без лимита
- Whitelist всегда логируется, чтобы заметить злоупотребление
10. Метрики
- 429 rate per endpoint / tenant
- Distribution: какие ключи чаще всего упираются (топ-10)
- p99 latency limiter'а самого по себе
- Redis errors (fallback rate)
- "Близко к лимиту" alerts (>80% использования)
Анти-паттерны
- Rate limit на nginx без коммуникации с приложением — приложение не знает почему 429
- Один лимит на всё ("1000 req/min на user") без учёта стоимости операции
- Не возвращать
Retry-After— клиенты будут долбить - Hash IP-адреса как ключ "ради приватности" → лимит работает, но дебажить невозможно
- Использовать sliding window log на high-RPS endpoint — память взорвётся
- Использовать только in-memory limiter за load balancer'ом — фактический лимит = ваш × число инстансов
В конце
- Выбранный алгоритм и почему именно он
- Схема ключей и уровней лимитов с конкретными числами
- Реализация (псевдокод или Lua-скрипт)
- Поведение при отказе Redis
- Формат 429 ответа
- Метрики и алерты
Multi-agent: координатор и специалисты
Архитектура из координатора и специализированных агентов: передача контекста, дедупликация, race conditions.
Новый subagent или новый skill: что выбрать
Decision tree: создавать ли отдельного агента или достаточно skill. Критерии — контекст, переиспользование, frequency, complexity.
Architecture Decision Record (ADR)
Зафиксировать архитектурное решение: контекст, варианты, выбор и trade-offs.