Модуль 5. Исходящий rate limit: лимиты на вызовы внешних API
Цель модуля: зеркальная задача Модуля 4 — не «отклонить лишних клиентов», а соблюсти лимиты чужого API (провайдера платежей, SMS, LLM-модели). Та же механика Token Bucket в Redis — другая семантика: reject превращается в throttle, плюс уважение Retry-After и circuit breaker.
Перед началом: Модули 3–4 (Lua Token Bucket, ключи лимитирования). Трудоёмкость: ~8–10 ч (теория 4 ч · практика 5 ч).
🔥 Инцидент: «Платёжный провайдер забанил нас на час»
После включения нового воркера платежей провайдер начал отдавать 429, а
затем на час закрыл ключ полностью. Смешно: у нас же стоял «лимитер» —
golang.org/x/time/rate с лимитом 100/мин. Вот только инстансов было
двенадцать, и каждый честно держал свои 100. Суммарно — 1200, в двенадцать
раз больше договорного. А когда провайдер начал отвечать 429, ретраи
воркеров добавили сверху — шторм усилили те, кто должен был его гасить.
Исходящий лимит — зеркальная задача входящего, но своя семантика: лимит
глобальный на компанию, а не на инстанс; отказ — это не «отклонить
клиента», а «притормозить себя» (throttle или очередь); и обязательно
уважение к Retry-After. В модуле — Token Bucket в Redis на исходящих
вызовах, режимы reject/wait и что делать, когда сам Redis недоступен, а
лимит нарушать нельзя.
📖 5.1. Почему исходящий лимит — это не входящий наоборот
Входящий: лишние запросы — чужие, их можно отклонить (429) без потерь. Исходящий: каждый вызов — ваша работа, которую надо выполнить, а 429 от провайдера означает: деньги уплачены, работа не сделана, плюс риск штрафов/блокировки ключа. Отсюда три отличия:
- Цель — не отклонить, а не превысить (throttle, а не reject): вызовы не отбрасываются, а сглаживаются/ожидают;
- Лимит известен из документации («300 req/min на ключ»,
«10 000 токенов/мин») — он же
rate, а burst = документированный мгновенный потолок или ваша амплуа; - Состояние общее на все инстансы сервиса: квота ключа одна на компанию — лимит обязан быть в Redis (global), локальные лимитеры тут только вредят (каждый инстанс думает, что у него своя квота).
Ключ лимитирования — провайдер + тариф/ключ: ext:sms-api:prod,
ext:payments:partner-a. Кластерный приоритет: с ростом числа
разных лимитеров каждый — свой ключ, совместимость автоматическая
(один ключ = один слот, Модуль 6).
📖 5.2. Тот же Token Bucket: Allow перед каждым вызовом
Бакет из Модуля 3 переиспользуется один в один:
go// Перед КАЖДЫМ вызовом внешнего API: allowed, retryAfter, err := limiter.Allow(ctx, "ext:sms-api:prod") if err != nil { // Redis недоступен: политика ЗАВИСИТ от цены превышения (§5.3) ... } if !allowed { return nil, &RateLimitedError{RetryAfter: retryAfter} } resp, err := smsClient.Send(ctx, ...)
Всплеск burst здесь работает как «банк мгновенных вызовов»: очередь
из 50 SMS уйдёт пачкой сразу, дальше — ровно rate в секунду. Согласуйте
burst с провайдером (у многих он тоже задокументирован).
📖 5.3. Reject vs Wait: два режима исходящего лимитера
Один и тот же бакет обслуживает два режима — и выбор определяет архитектуру вызывающего кода:
Reject (быстрый отказ) — вызов не делается, работа откладывается:
goallowed, retryAfter, _ := limiter.Allow(ctx, key) if !allowed { // работа возвращается в очередь/откладывается на retryAfter return ErrDefer{RetryAfter: retryAfter} }
Годится для фоновых задач и событий — «сделаем чуть позже».
Wait (троттлинг) — вызов ждёт свободный токен:
gofunc (t *ThrottledClient) wait(ctx context.Context, key string) error { for { allowed, retryAfter, err := t.limiter.Allow(ctx, key) if err != nil { return err // политика: fail-open (вызвать без лимита) или fail-close } if allowed { return nil } select { case <-ctx.Done(): return ctx.Err() // вызывающий не согласен ждать вечно case <-time.After(time.Duration(retryAfter) * time.Second): // спим РОВНО до «+1 токен» и пробуем снова } } }
Wait годится для интерактивных путей, где задержка приемлема (SMS-отправка фоном, bulk-обновление каталога), и НЕ годится там, где нужен мгновенный ответ пользователю.
Правило: reject — когда работа откладываемая; wait — когда задержка дешевле отказа; в одном клиенте возможны оба (по контексту вызова).
📖 5.4. Уважение к провайдеру: Retry-After, деградация, breaker
Настоящий провайдер communicates: 429 с заголовком Retry-After
(или JSON-полем reset). Игнорировать его — путь к блокировке ключа.
Минимальный протокол вежливости:
- Поймали 429 — считайте это сигналом «наш локальный бакет
разошёлся с реальностью»: приостановите вызовы на
Retry-Afterглобально (shared gate в Redis:SET blocked:{api} 1 EX retry_after— все инстансы увидят), а не только в этом горутине; - Понижайте эффективный rate: серия 429 подряд (например, 3)
→ временно
-50 %к rps, постепенный возврат при успехах (аддитивное повышение, мультипликативное понижение — AIMD); - Circuit breaker поверх лимитера: если доля ошибок провайдера высокая — разомкнуть цепь (fail-fast локальной ошибкой), полураскрытие пробной партией. Лимитер защищает провайдера от превышения, breaker — вас от его недоступности; это разные защиты, нужны обе;
- Логи 429 — с rate и заголовком провайдера: это материал для переговоров о повышении квоты.
📖 5.5. Очередь вызовов: worker pool + лимитер
Bulk-обработка (отправить 50 000 писем, обновить каталог) выглядит так:
gojobs := make(chan Job) sem := make(chan struct{}, concurrency) // параллелизм вызовов for i := 0; i < concurrency; i++ { go func() { for job := range jobs { if err := t.wait(ctx, key); err != nil { // throttle: ждём токен job.Fail(err) continue } resp, err := t.api.Call(ctx, job) if errors.Is(err, ErrRateLimitedByProvider) { backoff(job) // провайдер всё-таки вернул 429 — отложить continue } job.Done(resp) <-sem } }() }
concurrency× ожидание токенов — сглаживание трафика доrate;- ретраи — с джиттером, идемпотентные вызовы (ключ идемпотентности провайдеру, если он поддерживает);
- метрики: очередь ожидания, время ожидания токена (p99), число 429 — это ваш SLA перед бизнесом.
📖 5.6. Главное: Redis вышел из строя — как не превысить лимиты
Ключевой вопрос курса. Входящий лимитер при смерти Redis может fail-open (лишние запросы — чужие, потеря = деградация). Исходящий не может: fail-open = молчаливое превышение квоты провайдера = 429/штрафы/блокировка ключа. Значит, нужен заранее спроектированный fail-safe. Иерархия защиты — от быстрого к строгому:
1. Conservative local fallback (стандарт). Каждый инстанс держит
локальный in-memory бакет, но с консервативным лимитом: не «тот же
rate», а rate / max_expected_replicas (с запасом на скейлинг):
go// Пока Redis жив — точный кластерный бакет (Модуль 3). // Умер Redis — каждый инстанс продолжает считать ЧЕСТНО, // но в доле квоты: суммарный трафик ≤ provider_rate (при инстансов ≤ max). type FailSafeLimiter struct { global RateLimiter // Redis Token Bucket (Модуль 3) local *rate.Limiter // golang.org/x/time/rate redisErrors atomic.Int64 } func (f *FailSafeLimiter) Allow(ctx context.Context, key string) (bool, error) { ok, err := f.global.Allow(ctx, key) if err == nil { return ok, nil } // Redis недоступен → консервативный локальный бакет. // rate/N гарантирует: N инстансов суммарно не превысят квоту. f.redisErrors.Add(1) return f.local.Allow(ctx), nil }
Арифметика: квота 300 req/min, максимум 6 инстансов → локальный лимит 300/6 = 50 req/min на инстанс. Пока Redis жив — вы используете все 300; пока лежит — максимум 300 (а обычно меньше: недоиспользование). Цена деградации — недостаточное использование, не превышение.
2. Fail-close + очередь (для фоновых нагрузок). Если вызовы откладываемые — просто ждём Redis: вызовы ставятся в локальную очередь, воркер выпускает их после восстановления. Ноль превышений, ноль потерь, цена — задержка. Sentinel возвращает Redis за единицы секунд, поэтому окно — малое; для ночного bulk это лучший вариант.
3. БД-квота как источник правды (для денег и договорных лимитов). Когда превышение недопустимо юридически (SLA-договор, партнёрский ключ), счётчик ведут в PostgreSQL атомарным обновлением:
sqlUPDATE api_quota SET used = used + 1 WHERE key = 'sms-api' AND window = $1 AND used < $2 -- квота RETURNING used; -- 0 строк = квота исчерпана
Дорогой путь (round-trip в БД на вызов), но он переживает смерть Redis с точными лимитами. Для типовых внешних API (сотни-тысячи вызовов в минуту) PostgreSQL такое вывозит без проблем. Комбинация «Redis — быстрый предохранитель, БД — точный учёт» — тот же паттерн, что «кэш/локи против контракта БД» из Модуля 2.
4. Backpressure и breaker, пока лежит. Очередь вызовов (§5.5) —
с ограничением размера; circuit breaker на зависимости «Redis» и
«провайдер» — отдельные; алерт на redis_errors_total (Модуль 6) —
дежурный узнаёт о деградации раньше, чем провайдер.
📖 5.7. Правило выбора политики при отказе Redis
| Нагрузка | Лимиты | Что делать при смерти Redis |
|---|---|---|
| Фоновые/batch | мягкие | fail-close + очередь: ждём Redis (§5.6.2) |
| Интерактивные | жёсткие, но терпят burst | conservative local fallback (§5.6.1) |
| Деньги/договорные квоты | нарушать нельзя | БД-квота как источник правды (§5.6.3) |
И общий принцип, замыкающий курс: Redis — быстрый предохранитель; гарантию даёт тот, кто остаётся живым — локальный консервативный бакет, очередь или БД. Решение «что остаётся живым» принимается до инцидента, а не во время.
💻 Практика
- Mock-провайдер: маленький HTTP-сервис с честным limiter'ом 5 rps на ключ и 429+Retry-After при превышении.
- Клиент без защиты: гоните 50 rps — считайте 429 (больно).
- Клиент с throttle: wait-режим на Redis-бакете 5 rps/burst 10 — 429 = 0, throughput ровно ~5 rps, время ожидания в p99 приемлемо.
- Сломайте sync: два инстанса клиента с ЛОКАЛЬНЫМ лимитером по 5 rps → провайдер видит 10 rps и шлёт 429. Переведите на Redis-бакет (global) — 429 исчезли. Это мотивация Модуля 5 одной картинкой.
- Вежливость: mock вернул 429 с Retry-After=10 → клиент ставит общий gate — все инстансы молчат 10 секунд, потом продолжают.
- Главный экзамен (§5.6): под нагрузкой уроните Redis
(
docker compose stop redis). FailSafeLimiter переключается на консервативный local (50 req/min × 2 инстанса ≤ 300/мин квоты) — посчитайте на стороне mock-провайдера: превышений нет. Подняте Redis — точный кластерный бакет вернулся, недоиспользование исчезло.
Артефакт: throttled_client.go (wait + gate + breaker-скетч) + числа
экспериментов 2–4.
🧩 Quiz "Проверь себя"
✅ Итоги модуля
| Тема | Главное |
|---|---|
| Семантика | Исходящий = throttle: не превысить, а сгладить; 429 провайдера — дорого |
| Механика | Тот же Token Bucket в Redis; ключ = провайдер+ключ API; global обязателен |
| Режимы | Reject (откладываемая работа) vs Wait (задержка дешевле отказа) — контекстом вызова |
| Вежливость | Retry-After → общий gate; AIMD-понижение rate; breaker поверх лимитера |
| Bulk | worker pool + ожидание токенов; метрики ожидания — SLA перед бизнесом |
| Отказ Redis | Не fail-open: conservative local (rate/N реплик), fail-close+очередь, или БД-квота — по цене превышения |
🔗 Мостик к Модулю 6
Входящий и исходящий лимитеры готовы. Осталась эксплуатация: как это всё ведёт себя в кластере (failover, слоты, часы), какие метрики и алерты нужны лимитеру, как доказать точность конкурентным тестом — и финальный чек-лист продакшена. Плюс итоговая карта Redis-применений всего курса.