pulse-lets-go/internal/api/ratelimit.go
Maksim Totmin b0697da8e4 feat: weighted random routing + mode switching + EWMA smoothing + stale eviction
- Weighted random routing (math/rand/v2 per-goroutine ChaCha8)
- PUT /api/route/mode for runtime mode switch (best / weighted_random)
- EWMA score smoothing (configurable smoothing_factor, default 0.3)
- Periodic stale node eviction (5 min threshold, 60s interval)
- Non-blocking WS broadcast (per-client buffered channel + writePump)
- Background rate limiter cleanup goroutine
- In-memory trunk gateway cache (zero disk I/O in hot path)
- Configurable load_avg multiplier (default 50.0, backward compat)
- UI mode indicator + admin Switch button in dashboard
- 42 tests: weighted random, mode switching, EWMA, eviction, API integration
2026-06-25 20:42:46 +07:00

118 lines
3.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package api
import (
"net"
"net/http"
"sync"
"time"
)
// rateLimiter — in-memory token bucket rate limiter.
type rateLimiter struct {
mu sync.Mutex
buckets map[string]*tokenBucket
rate float64 // токенов в секунду
burst int // максимальный размер бакета
cleanupInterval time.Duration
}
type tokenBucket struct {
tokens float64
lastSeen time.Time
}
// newRateLimiter создаёт rate limiter с заданными параметрами.
// Запускает фоновую горутину очистки устаревших бакетов.
func newRateLimiter(ratePerSec, burst int) *rateLimiter {
if burst <= 0 {
burst = ratePerSec
}
rl := &rateLimiter{
buckets: make(map[string]*tokenBucket),
rate: float64(ratePerSec),
burst: burst,
cleanupInterval: 60 * time.Second,
}
// Фоновая горутина очистки — не блокирует hot path
go rl.cleanupLoop()
return rl
}
// allow проверяет, разрешён ли запрос для данного ключа.
// Возвращает true если разрешён, false если превышен лимит.
func (rl *rateLimiter) allow(key string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
b, exists := rl.buckets[key]
if !exists {
b = &tokenBucket{tokens: float64(rl.burst), lastSeen: now}
rl.buckets[key] = b
b.tokens--
return true
}
// Пополняем токены
elapsed := now.Sub(b.lastSeen).Seconds()
b.tokens += elapsed * rl.rate
if b.tokens > float64(rl.burst) {
b.tokens = float64(rl.burst)
}
b.lastSeen = now
if b.tokens >= 1.0 {
b.tokens--
return true
}
return false
}
// cleanupLoop — фоновая очистка устаревших бакетов.
func (rl *rateLimiter) cleanupLoop() {
ticker := time.NewTicker(rl.cleanupInterval)
defer ticker.Stop()
for range ticker.C {
rl.mu.Lock()
for k, b := range rl.buckets {
if time.Since(b.lastSeen) > rl.cleanupInterval {
delete(rl.buckets, k)
}
}
rl.mu.Unlock()
}
}
// rateLimitMiddleware создаёт middleware с rate limiting по IP.
// Ключ — RemoteAddr (или X-Forwarded-For, если за проксей).
func rateLimitMiddleware(limiter *rateLimiter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := stripPort(r.RemoteAddr)
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
key = fwd
}
if !limiter.allow(key) {
w.Header().Set("Retry-After", "1")
writeError(w, http.StatusTooManyRequests, "слишком много запросов")
return
}
next.ServeHTTP(w, r)
})
}
}
// stripPort убирает порт из адреса (напр. "[::1]:58544" → "::1", "127.0.0.1:1234" → "127.0.0.1").
func stripPort(addr string) string {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return addr // нет порта
}
return host
}