- 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
118 lines
3.0 KiB
Go
118 lines
3.0 KiB
Go
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
|
||
}
|