Files
glchat/internal/httpx/ratelimit.go
T
grendervill c059cd9f51 feat(api): лимиты частоты запросов (AGENT.md 8.6)
- httpx.RateLimiter: token bucket в памяти с уборкой неактивных ключей и
  подменяемым источником времени;
- login/register — 5 запросов в минуту на IP, весь API — 120 в минуту на
  пользователя (по хэшу токена сессии) или на IP для анонимных запросов;
- превышение отдаёт 429 с Retry-After и retry_after_ms в конверте ошибки;
- тесты: наполнение и пополнение ведра, независимость ключей, 429 на ручке
  входа.
2026-09-19 21:51:47 +03:00

134 lines
3.7 KiB
Go

package httpx
import (
"net/http"
"strconv"
"sync"
"time"
)
// RateLimiter — ограничитель частоты запросов по ключу (IP или пользователь).
// Реализация — token bucket без внешних зависимостей: 1 vCPU инстансу
// достаточно простого счётчика в памяти (AGENT.md 8.6).
type RateLimiter struct {
mu sync.Mutex
buckets map[string]*rateBucket
// rate — токенов в секунду, burst — максимальный запас.
rate float64
burst float64
// now подменяется в тестах, чтобы не ждать реального времени.
now func() time.Time
}
type rateBucket struct {
tokens float64
seen time.Time
}
// NewRateLimiter создаёт лимитер: perMinute запросов в минуту с запасом burst.
func NewRateLimiter(perMinute, burst int) *RateLimiter {
if perMinute <= 0 {
perMinute = 60
}
if burst <= 0 {
burst = perMinute
}
return &RateLimiter{
buckets: map[string]*rateBucket{},
rate: float64(perMinute) / 60,
burst: float64(burst),
now: time.Now,
}
}
// SetClock подменяет источник времени (тесты).
func (l *RateLimiter) SetClock(now func() time.Time) {
l.mu.Lock()
defer l.mu.Unlock()
l.now = now
}
// Allow проверяет запрос и возвращает паузу до следующей попытки.
func (l *RateLimiter) Allow(key string) (bool, time.Duration) {
if key == "" {
key = "unknown"
}
now := l.now()
l.mu.Lock()
defer l.mu.Unlock()
bucket, ok := l.buckets[key]
if !ok {
// Попутная уборка: словарь не должен расти бесконечно (AGENT.md 9.1).
if len(l.buckets) > 4096 {
l.sweepLocked(now)
}
bucket = &rateBucket{tokens: l.burst, seen: now}
l.buckets[key] = bucket
}
elapsed := now.Sub(bucket.seen).Seconds()
if elapsed > 0 {
bucket.tokens = minFloat(l.burst, bucket.tokens+elapsed*l.rate)
}
bucket.seen = now
if bucket.tokens < 1 {
missing := 1 - bucket.tokens
retryAfter := time.Duration(missing / l.rate * float64(time.Second))
if retryAfter < time.Second {
retryAfter = time.Second
}
return false, retryAfter
}
bucket.tokens--
return true, 0
}
// sweepLocked удаляет записи, неактивные больше десяти минут.
func (l *RateLimiter) sweepLocked(now time.Time) {
cutoff := now.Add(-10 * time.Minute)
for key, bucket := range l.buckets {
if bucket.seen.Before(cutoff) {
delete(l.buckets, key)
}
}
}
// Middleware ограничивает частоту запросов по ключу keyFn.
func (l *RateLimiter) Middleware(keyFn func(*http.Request) string) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
allowed, retryAfter := l.Allow(keyFn(r))
if !allowed {
WriteRateLimited(w, retryAfter)
return
}
next.ServeHTTP(w, r)
})
}
}
// WriteRateLimited отдаёт 429 в конверте API с подсказкой по паузе (§8.5, §8.6).
func WriteRateLimited(w http.ResponseWriter, retryAfter time.Duration) {
milliseconds := retryAfter.Milliseconds()
if milliseconds <= 0 {
milliseconds = 1000
}
w.Header().Set("Retry-After", strconv.FormatInt((milliseconds+999)/1000, 10))
WriteJSON(w, http.StatusTooManyRequests, map[string]any{
"error": map[string]any{
"code": "rate_limited",
"message": "too many requests",
"retry_after_ms": milliseconds,
},
})
}
func minFloat(a, b float64) float64 {
if a < b {
return a
}
return b
}