# 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 ## Архитектура ```mermaid 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`) ```go 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`) ```go 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`) ```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`) ```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? ```go 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). ## Заголовки ответа ```go 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 клиента ```go 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