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

@ -11,12 +11,14 @@ import (
// Config is the top-level configuration for the gosearch service.
type Config struct {
Server ServerConfig `toml:"server"`
Upstream UpstreamConfig `toml:"upstream"`
Engines EnginesConfig `toml:"engines"`
Cache CacheConfig `toml:"cache"`
CORS CORSConfig `toml:"cors"`
RateLimit RateLimitConfig `toml:"rate_limit"`
Server ServerConfig `toml:"server"`
Upstream UpstreamConfig `toml:"upstream"`
Engines EnginesConfig `toml:"engines"`
Cache CacheConfig `toml:"cache"`
CORS CORSConfig `toml:"cors"`
RateLimit RateLimitConfig `toml:"rate_limit"`
GlobalRateLimit GlobalRateLimitConfig `toml:"global_rate_limit"`
BurstRateLimit BurstRateLimitConfig `toml:"burst_rate_limit"`
}
type ServerConfig struct {
@ -59,6 +61,20 @@ type RateLimitConfig struct {
CleanupInterval string `toml:"cleanup_interval"` // Stale entry cleanup interval (default: "5m")
}
// GlobalRateLimitConfig holds server-wide rate limiting settings.
type GlobalRateLimitConfig struct {
Requests int `toml:"requests"` // Max total requests per window across all IPs (0 = disabled)
Window string `toml:"window"` // Time window (e.g. "1m", default: "1m")
}
// BurstRateLimitConfig holds per-IP burst rate limiting settings.
type BurstRateLimitConfig struct {
Burst int `toml:"burst"` // Max requests in burst window (0 = disabled)
BurstWindow string `toml:"burst_window"` // Burst window (default: "5s")
Sustained int `toml:"sustained"` // Max requests in sustained window
SustainedWindow string `toml:"sustained_window"` // Sustained window (default: "1m")
}
type BraveConfig struct {
APIKey string `toml:"api_key"`
AccessToken string `toml:"access_token"`
@ -159,6 +175,24 @@ func applyEnvOverrides(cfg *Config) {
if v := os.Getenv("RATE_LIMIT_CLEANUP_INTERVAL"); v != "" {
cfg.RateLimit.CleanupInterval = v
}
if v := os.Getenv("GLOBAL_RATE_LIMIT_REQUESTS"); v != "" {
fmt.Sscanf(v, "%d", &cfg.GlobalRateLimit.Requests)
}
if v := os.Getenv("GLOBAL_RATE_LIMIT_WINDOW"); v != "" {
cfg.GlobalRateLimit.Window = v
}
if v := os.Getenv("BURST_RATE_LIMIT_BURST"); v != "" {
fmt.Sscanf(v, "%d", &cfg.BurstRateLimit.Burst)
}
if v := os.Getenv("BURST_RATE_LIMIT_BURST_WINDOW"); v != "" {
cfg.BurstRateLimit.BurstWindow = v
}
if v := os.Getenv("BURST_RATE_LIMIT_SUSTAINED"); v != "" {
fmt.Sscanf(v, "%d", &cfg.BurstRateLimit.Sustained)
}
if v := os.Getenv("BURST_RATE_LIMIT_SUSTAINED_WINDOW"); v != "" {
cfg.BurstRateLimit.SustainedWindow = v
}
if v := os.Getenv("BASE_URL"); v != "" {
cfg.Server.BaseURL = v
}
@ -201,6 +235,30 @@ func (c *Config) RateLimitCleanupInterval() time.Duration {
return 5 * time.Minute
}
// GlobalRateLimitWindow parses the global rate limit window into a time.Duration.
func (c *Config) GlobalRateLimitWindow() time.Duration {
if d, err := time.ParseDuration(c.GlobalRateLimit.Window); err == nil && d > 0 {
return d
}
return time.Minute
}
// BurstWindow parses the burst window into a time.Duration.
func (c *Config) BurstWindow() time.Duration {
if d, err := time.ParseDuration(c.BurstRateLimit.BurstWindow); err == nil && d > 0 {
return d
}
return 5 * time.Second
}
// SustainedWindow parses the sustained window into a time.Duration.
func (c *Config) SustainedWindow() time.Duration {
if d, err := time.ParseDuration(c.BurstRateLimit.SustainedWindow); err == nil && d > 0 {
return d
}
return time.Minute
}
func splitCSV(s string) []string {
if s == "" {
return nil