backend-rate-limiting.md 7.3 KB

Backend: Rate Limiting — Redis с graceful fallback

Контекст: В backend/cmd/api/main.go реализована гибридная стратегия rate limiting: Redis (sliding window через ulule/limiter/v3) как primary, in-memory token bucket (golang.org/x/time/rate) как fallback. Обновлено 2026-06 — код использует Redis + in-memory fallback, а НЕ только in-memory, как было в первой версии заметки.

Суть

Приложение использует два механизма rate limiting одновременно:

  1. Redis (middleware/ratelimit_redis.go) — sliding window алгоритм через ulule/limiter/v3, работает в кластере
  2. In-memory (middleware/ratelimit.go) — token bucket на golang.org/x/time/rate, fallback при недоступности Redis

Архитектура

graph TD
    Request-->RateLimiter
    RateLimiter-->Redis{Redis ping ok?}
    Redis-->|Yes|RedisRL[Redis Sliding Window]
    Redis-->|No|Decision{isProduction?}
    Decision-->|Yes prod|FailClosed[503 Service Unavailable]
    Decision-->|No dev|InMemory[In-Memory Token Bucket]
    RedisRL-->|Rate Exceeded|Block[429 Too Many Requests]
    InMemory-->|Rate Exceeded|Block
    RedisRL-->|OK|Next[Next handler]
    InMemory-->|OK|Next

Реализация

Создание лимитера с fallback (main.go:325-342)

func newRateLimiterWithFallback(
    redisClient *redis.Client,
    redisAvailable bool,
    logger *slog.Logger,
    failOpen bool,
    redisLimiterFn func(*redis.Client, *slog.Logger, bool) (rateLimiterInterface, error),
    inMemoryLimiterFn func() *middleware.RateLimiter,
) rateLimiterInterface {
    if redisAvailable {
        if limiter, err := redisLimiterFn(redisClient, logger, failOpen); err == nil {
            return limiter
        }
    }
    return inMemoryLimiterFn()
}

Fail-open vs Fail-closed (main.go:119)

failOpen := cfg.AppEnv != "production"
  • Development (failOpen = true): Redis недоступен → in-memory fallback. Сервис работает, но rate limiting сбрасывается при рестарте.
  • Production (failOpen = false): Redis недоступен → 503 Service Unavailable. Сервис недоступен, но защищён от abuse.

Redis rate limiter (ratelimit_redis.go)

func NewRedisRateLimiter(redisClient *redis.Client, config RateLimitConfig) (*RedisRateLimiter, error) {
    store, _ := limiterRedis.NewStoreWithOptions(redisClient, limiter.StoreOptions{
        Prefix:   "ratelimit",
        MaxRetry: 3,
    })
    instance := limiter.New(store, config.Rate)
    return &RedisRateLimiter{
        instance: instance,
        keyFunc:  config.KeyFunc,
        failOpen: config.FailOpen,
    }, nil
}

Timeout: 100ms на запрос к Redis. Если Redis отвечает дольше — считаем его недоступным.

In-memory rate limiter (ratelimit.go)

func (rl *RateLimiter) Middleware() func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ip := GetClientIP(r)
            rl.mu.Lock()
            limiter, ok := rl.visitors[ip]
            if !ok {
                limiter = rate.NewLimiter(rl.rate, rl.burst)
                rl.visitors[ip] = limiter
            }
            rl.lastSeen[ip] = time.Now()
            rl.mu.Unlock()

            if !limiter.Allow() {
                http.Error(w, `{"error":"rate limit exceeded"}`, 429)
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

⚠️ Известная проблема: мьютекс не использует defer — при панике в GetClientIP будет deadlock. См. [[atomic-mutex-defer-panic]].

Настройки для разных эндпоинтов

Эндпоинты Rate limit Ключ Реализация
/auth/* (register, login, refresh) 10 req/min IP (auth:{ip}) KeyByIP("auth")
GET /places, /reviews, /tags, /services 60 req/min UserID или IP (api_read:{user_id} или api_read:ip:{ip}) KeyByUserID("api_read")
POST /places, /bookings, /reviews 10 req/min UserID или IP KeyByUserID("api_write")
/admin/* (moderate, manage users, tags) 100 req/min UserID KeyByUserID("admin")

Почему KeyByUserID с fallback?

func KeyByUserID(prefix string) func(*http.Request) string {
    return func(r *http.Request) string {
        userID := GetUserID(r.Context())
        if userID != "" {
            return prefix + ":user:" + userID
        }
        return prefix + ":ip:" + GetClientIP(r) // fallback для неавторизованных
    }
}

Авторизованные пользователи идентифицируются по user_id (стабильный ключ). Неавторизованные — по IP (менее стабильно, но защищает от анонимного abuse).

Заголовки ответа

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))

Клиент может отслеживать оставшиеся запросы через эти заголовки.

Как определяется IP клиента

func GetClientIP(r *http.Request) string {
    if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
        parts := strings.Split(fwd, ",")
        return strings.TrimSpace(parts[0]) // берём первый IP
    }
    if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
        return realIP
    }
    // fallback: удаляем порт из RemoteAddr
    addr := r.RemoteAddr
    if idx := strings.LastIndex(addr, ":"); idx != -1 {
        return addr[:idx]
    }
    return addr
}

Важно: X-Forwarded-For доверяется, только если Caddy/nginx настроен на его установку. Иначе клиент может подделать IP.

Проверки в production

  • Redis работает в кластерном режиме (Sentinel/Cluster)?
  • failOpen = false для production?
  • Timeout Redis 100ms не слишком мал для геораспределённой infra?
  • Есть ли мониторинг/alert при падении Redis?
  • Rate limit ключи не пересекаются (auth vs api_read vs admin)?
  • X-Forwarded-For устанавливается только trusted proxy (Caddy/nginx)?

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

  • [[decision-rate-limiter-redis]] — Decision record: Redis выбор
  • [[architecture-overview]] — Общий обзор
  • [[atomic-redis-rate-limiter-failopen-failclosed]] — Fail-open/closed стратегия
  • [[deploy-production-readiness]] — Production готовность

Источник

Code review PhotoPlaces 2026-06. Актуализация — Redis + in-memory гибрид уже реализован.

Теги

#backend #rate-limiting #redis #scaling #best-practice