feat: add global and burst rate limiters

Three layers of rate limiting, all disabled by default, opt-in via config:

1. Per-IP (existing): 30 req/min per IP
2. Global: server-wide limit across all IPs
   - Lock-free atomic counter for minimal overhead
   - Returns 503 when exceeded
   - Prevents pool exhaustion from distributed attacks
3. Burst: per-IP burst + sustained windows
   - Blocks rapid-fire abuse within seconds
   - Returns 429 with X-RateLimit-Reason header
   - Example: 5 req/5s burst, 60 req/min sustained

Config:
[global_rate_limit]
requests = 0  # disabled by default
window = "1m"

[burst_rate_limit]
burst = 0  # disabled by default
burst_window = "5s"
sustained = 0
sustained_window = "1m"

Env overrides: GLOBAL_RATE_LIMIT_REQUESTS, GLOBAL_RATE_LIMIT_WINDOW,
BURST_RATE_LIMIT_BURST, BURST_RATE_LIMIT_BURST_WINDOW,
BURST_RATE_LIMIT_SUSTAINED, BURST_RATE_LIMIT_SUSTAINED_WINDOW

Full test coverage: concurrent lock-free test, window expiry, disabled states,
IP isolation, burst vs sustained distinction.
This commit is contained in:
Franz Kafka 2026-03-21 18:35:31 +00:00
parent 91ab76758c
commit 13040268d6
7 changed files with 657 additions and 18 deletions

View file

@ -45,13 +45,12 @@ func RateLimit(cfg RateLimitConfig, logger *slog.Logger) func(http.Handler) http
}
limiter := &ipLimiter{
requests: requests,
window: window,
clients: make(map[string]*bucket),
logger: logger,
requests: requests,
window: window,
clients: make(map[string]*bucket),
logger: logger,
}
// Background cleanup of stale buckets.
go limiter.cleanup(cleanup)
return func(next http.Handler) http.Handler {
@ -122,13 +121,9 @@ func (l *ipLimiter) cleanup(interval time.Duration) {
}
func extractIP(r *http.Request) string {
// Trust X-Forwarded-For / X-Real-IP if behind a proxy.
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// First IP in the chain is the client.
if idx := len(xff); idx > 0 {
parts := strings.SplitN(xff, ",", 2)
return strings.TrimSpace(parts[0])
}
parts := strings.SplitN(xff, ",", 2)
return strings.TrimSpace(parts[0])
}
if rip := r.Header.Get("X-Real-IP"); rip != "" {
return strings.TrimSpace(rip)