Модуль 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 от провайдера означает: деньги уплачены, работа не сделана, плюс риск штрафов/блокировки ключа. Отсюда три отличия:

  1. Цель — не отклонить, а не превысить (throttle, а не reject): вызовы не отбрасываются, а сглаживаются/ожидают;
  2. Лимит известен из документации («300 req/min на ключ», «10 000 токенов/мин») — он же rate, а burst = документированный мгновенный потолок или ваша амплуа;
  3. Состояние общее на все инстансы сервиса: квота ключа одна на компанию — лимит обязан быть в 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 (быстрый отказ) — вызов не делается, работа откладывается:

go
allowed, retryAfter, _ := limiter.Allow(ctx, key)
if !allowed {
    // работа возвращается в очередь/откладывается на retryAfter
    return ErrDefer{RetryAfter: retryAfter}
}

Годится для фоновых задач и событий — «сделаем чуть позже».

Wait (троттлинг) — вызов ждёт свободный токен:

go
func (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). Игнорировать его — путь к блокировке ключа. Минимальный протокол вежливости:

  1. Поймали 429 — считайте это сигналом «наш локальный бакет разошёлся с реальностью»: приостановите вызовы на Retry-After глобально (shared gate в Redis: SET blocked:{api} 1 EX retry_after — все инстансы увидят), а не только в этом горутине;
  2. Понижайте эффективный rate: серия 429 подряд (например, 3) → временно -50 % к rps, постепенный возврат при успехах (аддитивное повышение, мультипликативное понижение — AIMD);
  3. Circuit breaker поверх лимитера: если доля ошибок провайдера высокая — разомкнуть цепь (fail-fast локальной ошибкой), полураскрытие пробной партией. Лимитер защищает провайдера от превышения, breaker — вас от его недоступности; это разные защиты, нужны обе;
  4. Логи 429 — с rate и заголовком провайдера: это материал для переговоров о повышении квоты.

📖 5.5. Очередь вызовов: worker pool + лимитер

Bulk-обработка (отправить 50 000 писем, обновить каталог) выглядит так:

go
jobs := 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 атомарным обновлением:

sql
UPDATE 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)
Интерактивныежёсткие, но терпят burstconservative local fallback (§5.6.1)
Деньги/договорные квотынарушать нельзяБД-квота как источник правды (§5.6.3)

И общий принцип, замыкающий курс: Redis — быстрый предохранитель; гарантию даёт тот, кто остаётся живым — локальный консервативный бакет, очередь или БД. Решение «что остаётся живым» принимается до инцидента, а не во время.


💻 Практика

  1. Mock-провайдер: маленький HTTP-сервис с честным limiter'ом 5 rps на ключ и 429+Retry-After при превышении.
  2. Клиент без защиты: гоните 50 rps — считайте 429 (больно).
  3. Клиент с throttle: wait-режим на Redis-бакете 5 rps/burst 10 — 429 = 0, throughput ровно ~5 rps, время ожидания в p99 приемлемо.
  4. Сломайте sync: два инстанса клиента с ЛОКАЛЬНЫМ лимитером по 5 rps → провайдер видит 10 rps и шлёт 429. Переведите на Redis-бакет (global) — 429 исчезли. Это мотивация Модуля 5 одной картинкой.
  5. Вежливость: mock вернул 429 с Retry-After=10 → клиент ставит общий gate — все инстансы молчат 10 секунд, потом продолжают.
  6. Главный экзамен (§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 "Проверь себя"

1. Reject или Wait для фоновых SMS-уведомлений при исчерпании токенов?
2. Провайдер вернул 429 с Retry-After: 10. Что сделает вежливый клиент?
3. Договорной SLA с провайдером нельзя нарушать даже при отказе Redis. Какой источник правды по квоте?
4. Исходящий лимитер держит счётчик в Redis, Redis умер на 20 секунд. Квота провайдера 300/мин, 6 инстансов, превышать нельзя. Какая деградация корректна?
5. Два инстанса клиента, каждый с локальным лимитером 5 rps, квота провайдера 5 rps на ключ. Что будет?
6. Чем исходящий rate limit отличается по семантике от входящего?
7. Фоновая bulk-загрузка, лимиты провайдера нарушать нельзя даже на секунду. Поведение при смерти Redis?

✅ Итоги модуля

ТемаГлавное
СемантикаИсходящий = throttle: не превысить, а сгладить; 429 провайдера — дорого
МеханикаТот же Token Bucket в Redis; ключ = провайдер+ключ API; global обязателен
РежимыReject (откладываемая работа) vs Wait (задержка дешевле отказа) — контекстом вызова
ВежливостьRetry-After → общий gate; AIMD-понижение rate; breaker поверх лимитера
Bulkworker pool + ожидание токенов; метрики ожидания — SLA перед бизнесом
Отказ RedisНе fail-open: conservative local (rate/N реплик), fail-close+очередь, или БД-квота — по цене превышения

🔗 Мостик к Модулю 6

Входящий и исходящий лимитеры готовы. Осталась эксплуатация: как это всё ведёт себя в кластере (failover, слоты, часы), какие метрики и алерты нужны лимитеру, как доказать точность конкурентным тестом — и финальный чек-лист продакшена. Плюс итоговая карта Redis-применений всего курса.