Контекст: В 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 одновременно:
middleware/ratelimit_redis.go) — sliding window алгоритм через ulule/limiter/v3, работает в кластереmiddleware/ratelimit.go) — token bucket на golang.org/x/time/rate, fallback при недоступности Redisgraph 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
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()
}
main.go:119)failOpen := cfg.AppEnv != "production"
failOpen = true): Redis недоступен → in-memory fallback. Сервис работает, но rate limiting сбрасывается при рестарте.failOpen = false): Redis недоступен → 503 Service Unavailable. Сервис недоступен, но защищён от abuse.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 отвечает дольше — считаем его недоступным.
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))
Клиент может отслеживать оставшиеся запросы через эти заголовки.
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.
failOpen = false для production?X-Forwarded-For устанавливается только trusted proxy (Caddy/nginx)?Code review PhotoPlaces 2026-06. Актуализация — Redis + in-memory гибрид уже реализован.
#backend #rate-limiting #redis #scaling #best-practice