Модуль 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(тяжёлые эндпоинты дороже).
gofunc 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-логикой —
лимит обходится одним заголовком на запрос. Для открытого сервиса:
gokeyFunc := 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:
gofunc 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
| Режим | Механика | Плюсы | Минусы |
|---|---|---|---|
| local | in-memory x/time/rate на инстансе | мкс, без зависимости | лимит × N реплик; сброс при рестарте |
| global | Redis Token Bucket (М3) | точный кластерный лимит, живёт в перезапусках | сетевой round-trip на каждый запрос; Redis — зависимость |
| hybrid | local отсекает грубо → 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.
💻 Практика
- Middleware + заголовки: соберите сервис с hybrid-лимитером (local 10 rps/burst 20, global 50 rps/burst 100); curl -i покажет X-RateLimit-*; прострелите лимит — 429 с Retry-After.
- Локальный лимит умножается: запустите 2 инстанса в режиме local за балансировщиком (docker compose, nginx) — суммарный пропущенный трафик вдвое выше лимита одного.
- Гибрид точен: те же 2 инстанса в hybrid — суммарно ровно
global-лимит; в заголовке
X-RateLimit-Sourceвидно уровень решения. - Деградация:
docker compose stop redisпод нагрузкой — трафик продолжает идти (fallback),redis_errors_totalрастёт, Source становитсяhybrid:local-fallback. - Подмена XFF: открытый сервис +
curl -H 'X-Forwarded-For: 1.2.3.4'× 100 — лимит не работает; переключите keyFunc на RemoteAddr — работает. Один тест, который стоит всего курса по безопасности лимитирования.
Артефакт: middleware + два эксперимента (умножение local, деградация hybrid) + тест XFF-подмены.
🧩 Quiz "Проверь себя"
✅ Итоги модуля
| Тема | Главное |
|---|---|
| Ключ | IP до аутентификации (RemoteAddr без прокси!), user_id/API key после; XFF — только за доверенным прокси |
| Middleware | X-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.