decision-rate-limiter-redis.md 5.7 KB

Decision Record: Redis-based Rate Limiting

Дата: 2026-06-11 Статус: Accepted (требует реализации)

Контекст

Текущий rate limiter в backend/internal/middleware/ratelimit.go использует in-memory golang.org/x/time/rate. В main.go:54 создаётся глобальный лимитер на 60 req/min на IP.

Проблема

In-memory rate limiter не работает в production при:

  • Горизонтальном масштабировании (multiple replicas)
  • Рестартах контейнеров (сброс счётчиков)
  • Балансировке нагрузки (разные IP на разных инстансах)

Решение

Использовать Redis-backed rate limiter с библиотекой github.com/ulule/limiter/v3.

Почему ulule/limiter:

  • Поддерживает Redis, in-memory, Memcached
  • Sliding window / token bucket алгоритмы
  • Атомарные операции через Lua-скрипты
  • Гибкие ключи (IP, UserID, custom)
  • Готовые middleware для Chi, Gin, Echo, stdlib

Реализация

// internal/middleware/ratelimit_redis.go
package middleware

import (
    "context"
    "net/http"
    "time"

    "github.com/redis/go-redis/v9"
    "github.com/ulule/limiter/v3"
    "github.com/ulule/limiter/v3/drivers/store/redis"
    "github.com/go-chi/chi/v5/middleware"
)

type RedisRateLimiter struct {
    instance *limiter.Limiter
    keyFunc  func(*http.Request) string
}

func NewRedisRateLimiter(redisClient *redis.Client, rate limiter.Rate, keyFunc func(*http.Request) string) *RedisRateLimiter {
    store := redis.NewStoreWithOptions(redisClient, limiter.StoreOptions{Prefix: "ratelimit"})
    instance := limiter.New(store, rate)
    return &RedisRateLimiter{instance: instance, keyFunc: keyFunc}
}

func (r *RedisRateLimiter) Middleware() func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
            key := r.keyFunc(req)
            ctx := context.Background()
            
            limitCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
            defer cancel()
            
            limit, err := r.instance.Get(limitCtx, key)
            if err != nil {
                // Fail open или fail closed — решение бизнеса
                // Рекомендую fail open с логированием
                next.ServeHTTP(w, req)
                return
            }
            
            w.Header().Set("X-RateLimit-Limit", strconv.FormatInt(limit.Limit, 10))
            w.Header().Set("X-RateLimit-Remaining", strconv.FormatInt(limit.Remaining, 10))
            w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(limit.Reset, 10))
            
            if limit.Reached {
                writeRateLimitError(w, limit.Reset)
                return
            }
            
            next.ServeHTTP(w, req)
        })
    }
}

// В main.go — настройка разных лимитов для разных групп
func setupRateLimiters(r *chi.Mux, redisClient *redis.Client) {
    // Auth endpoints: 10/min по IP
    authLimiter := NewRedisRateLimiter(redisClient, limiter.Rate{Period: time.Minute, Limit: 10}, func(r *http.Request) string {
        return "auth:" + middleware.GetIP(r)
    })
    r.Group(func(r chi.Router) {
        r.Use(authLimiter.Middleware())
        r.Post("/auth/register", ...)
        r.Post("/auth/login", ...)
    })
    
    // API read: 60/min по UserID (если авторизован) или IP
    apiReadLimiter := NewRedisRateLimiter(redisClient, limiter.Rate{Period: time.Minute, Limit: 60}, func(r *http.Request) string {
        if userID := middleware.GetUserID(r.Context()); userID != "" {
            return "api_read:user:" + userID
        }
        return "api_read:ip:" + middleware.GetIP(r)
    })
    r.Group(func(r chi.Router) {
        r.Use(apiReadLimiter.Middleware())
        r.Get("/places", ...)
        r.Get("/services", ...)
    })
    
    // API write: 10/min по UserID
    apiWriteLimiter := NewRedisRateLimiter(redisClient, limiter.Rate{Period: time.Minute, Limit: 10}, func(r *http.Request) string {
        return "api_write:user:" + middleware.GetUserID(r.Context())
    })
    r.Group(func(r chi.Router) {
        r.Use(apiWriteLimiter.Middleware())
        r.Post("/places", ...)
        r.Post("/services", ...)
    })
}

Альтернативы (отклонены)

Вариант Почему отклонен
nginx rate limiting Нет per-user лимитов, меньше гибкости, сложнее тестировать
Cloudflare / API Gateway Vendor lock-in, стоимость, задержка конфигурации
In-memory с sticky sessions Антипаттерн, ломает масштабируемость

Последствия

Положительные:

  • Работает в кластере
  • Гибкие правила per endpoint / per user / per IP
  • Стандартные заголовки X-RateLimit-*
  • Атомарность через Redis Lua

Отрицательные:

  • Зависимость от доступности Redis (fail open стратегия)
  • Небольшая латентность (~1-2ms)
  • Нужно мониторить Redis память

Связанные заметки

  • [[backend-rate-limiting]]
  • [[deploy-production-readiness]] — Redis HA для прод
  • [[architecture-overview]]

Теги

#decision-record #rate-limiting #redis #scaling #architecture