Модуль 4. Входящие запросы: middleware, ключи лимитирования и гибрид local+global

Цель модуля: встроить Token Bucket из Модуля 3 в путь HTTP-запроса: middleware с честными заголовками, выбор ключа лимитирования (и атака подменой X-Forwarded-For), и production-архитектура local + global — почему гибрид экономит латентность, сам Redis и не разваливается при отказе.

Перед началом: Модуль 3 (Lua Token Bucket), Модуль 1 (политики отказа). Инструменты: golang.org/x/time/rate, стенд boilerplate. Трудоёмкость: ~8–10 ч (теория 4 ч · практика 5 ч).


🔥 Инцидент: «Один бакет на весь офис»

Служба поддержки завалила дежурного: «у клиента N всё отдаёт 429, но у них мало трафика — три человека». Лимит был настроен на пользователя: 100 запросов в минуту, ключ — по IP. А у клиента N весь офис сидел за корпоративным NAT: триста сотрудников с одного адреса. Их суммарный честный трафик выскочил за лимит одного «пользователя» — и лимитер начал душить всех сразу, включая тех, кто не нажимал ничего.

Это обратная сторона выбора ключа лимитирования: ключ по IP защищает от ботов и ломает офисы; ключ по токену не работает для анонимов; ключ по X-Forwarded-For доверяет заголовку, который подделывает любой. В модуле разбираем оси лимитирования (кто вообще «один клиент»), middleware с честными заголовками 429 и production-архитектуру local + global, где быстрый локальный фильтр снимает латентность, а глобальный арбитр держит сумму.

📖 4.1. Ключ лимитирования: кого считаем

Лимит считается на ключ — идентификатор субъекта. Выбор ключа — это продуктовое решение:

  • IP — защита от анонимного злоупотребления до аутентификации;
  • user_id — персональные лимиты после аутентификации (точнее IP: NAT прячет сотни пользователей за одним адресом);
  • API key — тарифные планы;
  • составные: route + user (тяжёлые эндпоинты дороже).
go
func DefaultKeyFunc(r *http.Request) (string, error) {
    if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
        // "client, proxy1, proxy2" — берём ПЕРВОГО (клиента)
        return xff[:strings.IndexByte(xff, ',')], nil
    }
    if xri := r.Header.Get("X-Real-IP"); xri != "" {
        return xri, nil
    }
    ip, _, err := net.SplitHostPort(r.RemoteAddr)
    return ip, err
}

Атака подменой: X-Forwarded-For клиент может прислать сам. Доверять заголовку можно только если сервис стоит за вашим доверенным прокси (nginx/ingress перезапишет XFF). Прямо открытый сервис с XFF-логикой — лимит обходится одним заголовком на запрос. Для открытого сервиса:

go
keyFunc := func(r *http.Request) (string, error) {
    ip, _, err := net.SplitHostPort(r.RemoteAddr) // только TCP-источник
    return ip, err
}

После аутентификации переключайтесь на user: + id — уникальный пользовательский лимит, устойчивый к NAT.


📖 4.2. Middleware: заголовки, 429 и fail-open

Обязательства middleware:

go
func Middleware(limiter Limiter, keyFunc KeyFunc, logger *slog.Logger) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            key, err := keyFunc(r)
            if err != nil {
                next.ServeHTTP(w, r) // нет ключа — не блокируем, логируем
                return
            }
            result, err := limiter.Allow(r.Context(), key)
            if err != nil {
                next.ServeHTTP(w, r) // fail-open: доступность важнее (Модуль 1)
                return
            }
            // Заголовки — ВСЕГДА: клиент видит своё состояние и до лимита
            w.Header().Set("X-RateLimit-Limit", strconv.Itoa(result.Limit))
            w.Header().Set("X-RateLimit-Remaining", strconv.Itoa(result.Remaining))
            w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(result.ResetAfter, 10))
            if !result.Allowed {
                w.Header().Set("Retry-After", strconv.FormatInt(result.RetryAfter, 10))
                w.Header().Set("Content-Type", "application/json; charset=utf-8")
                w.WriteHeader(http.StatusTooManyRequests)
                json.NewEncoder(w).Encode(map[string]any{
                    "error": "Rate limit exceeded", "retry_after": result.RetryAfter,
                })
                metrics.RateLimitExceeded.Inc()
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

Решения, которые здесь спрятаны:

  • X-RateLimit-Remaining на каждом ответе — клиент сам замедляется, не долетая до 429 (так работают GitHub/Stripe);
  • Retry-After при 429 — вежливость, экономящая и ваш трафик (модуль 5: этим же заголовком вы сами пользуетесь как клиент);
  • fail-open при ошибке лимитера — осознанный выбор для защиты: упал Redis → пропускаем, а не отказываем всем. Для биллинговых лимитов — пересмотреть (fail-close), решение должно быть ЯВНЫМ и закомментированным;
  • Место в цепочке: после Recovery/Logging/Metrics (отклонённые запросы видны в метриках), до Timeout (не тратить бюджет на гарантированный 429).

📖 4.3. Три режима: local, global, hybrid

РежимМеханикаПлюсыМинусы
localin-memory x/time/rate на инстансемкс, без зависимостилимит × N реплик; сброс при рестарте
globalRedis Token Bucket (М3)точный кластерный лимит, живёт в перезапускахсетевой round-trip на каждый запрос; Redis — зависимость
hybridlocal отсекает грубо → global добирает точнобыстро + точно + деградациячуть сложнее

Local-режим на N репликах даёт N × rps суммарно — и при автоскейлинге с 2 до 10 подов «лимит» вырастает впятеро. Для защитных лимитов иногда приемлемо, для тарифов — нет.


📖 4.4. Гибрид: local — фильтр, global — арбитр

Production-архитектура (из боевого шаблона) — двухступенчатый конвейер:

запрос → [LOCAL in-memory]  ──отклонено──► 429 (Redis не трогали!)
              │ пропущено
              ▼
         [GLOBAL Redis]     ──отклонено──► 429 (точный кластерный лимит)
              │ разрешено
              ▼
           хендлер
  • Local с заниженным лимитом (global_rps / ожидаемое число реплик с запасом) отсекает 95–99 % лишнего трафика без похода в Redis;
  • Global гарантирует кластерную точность тем, кто прошёл local;
  • Graceful degradation: Redis недоступен → возвращается вердикт local с меткой hybrid:local-fallback — сервис продолжает работать, лимит временно пер-инстансный. Метрика ratelimit_redis_errors_total по этой ситуации ставит алерт (Модуль 6);
  • Источник решения (Result.Source: hybrid:local, hybrid:global, hybrid:local-fallback…) пишется в заголовок и метрику — на разборе инцидента сразу видно, кто отклонил.

Считайте разницу цен: local — обращение к sync.Mutex (~50 нс); global — сетевой round-trip к Redis (~0.5–2 мс) и Redis-команда. При 10 000 rps экономия похода в Redis — это 20 Redis-команд/с вместо 10 000.


💻 Практика

  1. Middleware + заголовки: соберите сервис с hybrid-лимитером (local 10 rps/burst 20, global 50 rps/burst 100); curl -i покажет X-RateLimit-*; прострелите лимит — 429 с Retry-After.
  2. Локальный лимит умножается: запустите 2 инстанса в режиме local за балансировщиком (docker compose, nginx) — суммарный пропущенный трафик вдвое выше лимита одного.
  3. Гибрид точен: те же 2 инстанса в hybrid — суммарно ровно global-лимит; в заголовке X-RateLimit-Source видно уровень решения.
  4. Деградация: docker compose stop redis под нагрузкой — трафик продолжает идти (fallback), redis_errors_total растёт, Source становится hybrid:local-fallback.
  5. Подмена XFF: открытый сервис + curl -H 'X-Forwarded-For: 1.2.3.4' × 100 — лимит не работает; переключите keyFunc на RemoteAddr — работает. Один тест, который стоит всего курса по безопасности лимитирования.

Артефакт: middleware + два эксперимента (умножение local, деградация hybrid) + тест XFF-подмены.


🧩 Quiz "Проверь себя"

1. Redis упал, режим hybrid. Что происходит с трафиком?
2. Почему local-ступень ставят ПЕРЕД global?
3. 4 реплики в режиме local по 100 rps. Каков кластерный лимит?
4. Сервис открыт в интернете (без прокси) и лимитирует по X-Forwarded-For. Что не так?

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

ТемаГлавное
КлючIP до аутентификации (RemoteAddr без прокси!), user_id/API key после; XFF — только за доверенным прокси
MiddlewareX-RateLimit-* всегда, 429 + Retry-After, fail-open — явно; после Metrics, до Timeout
Режимыlocal (быстро, ×N реплик), global (точно, зависит), hybrid (рекомендуется)
Гибридlocal — фильтр, global — арбитр; fallback при отказе Redis с меткой в Source
Экономикаlocal ~50 нс, global ~0.5–2 мс — на высоких rps гибрид бережёт и латентность, и Redis

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

Мы научились защищать себя. Зеркальная задача — не навредить другим: лимитирование собственных вызовов внешних API, где 429 означает не «отклонён запрос», а «вы платите деньгами/репутацией» и нужно не отклонять, а ждать. Другая семантика, тот же Token Bucket, плюс уважение Retry-After и circuit breaker.