// Package ratelimit implements a per-key token-bucket rate limiter — no // third-party rate-limiting library. A token bucket (rather than a hard // fixed-window reset) is used deliberately: it smooths out bursts at // window boundaries that a naive "reset every 60s" counter would allow // (e.g. 20 requests at 0:59 plus another 20 at 1:01 both passing a // "20/min" limit reset at the minute boundary). Safe for concurrent use. package ratelimit import ( "sync" "time" ) type bucket struct { tokens float64 lastRefill time.Time } // Limiter enforces "at most ratePerMinute events per key, per minute" with // burst tolerance up to ratePerMinute tokens banked at once (i.e. a key // that's been idle can burst up to the full per-minute allowance instantly, // then is throttled to the steady-state rate — standard token-bucket // behavior, not a stricter "evenly spaced" enforcement). type Limiter struct { ratePerMinute float64 mu sync.Mutex buckets map[string]*bucket stopCleanup chan struct{} } // New creates a limiter allowing ratePerMinute events per key. Pass 0 to // disable limiting entirely (Allow always returns true) — this is how a // zero/unset config value opts a listener out of rate limiting rather than // silently blocking everything. func New(ratePerMinute int) *Limiter { l := &Limiter{ ratePerMinute: float64(ratePerMinute), buckets: make(map[string]*bucket), stopCleanup: make(chan struct{}), } if ratePerMinute > 0 { go l.cleanupLoop() } return l } // Allow reports whether an event for key is permitted right now, consuming // one token if so. Safe to call from many goroutines concurrently. func (l *Limiter) Allow(key string) bool { if l.ratePerMinute <= 0 { return true } l.mu.Lock() defer l.mu.Unlock() now := time.Now() b, ok := l.buckets[key] if !ok { b = &bucket{tokens: l.ratePerMinute - 1, lastRefill: now} l.buckets[key] = b return true } elapsed := now.Sub(b.lastRefill).Seconds() refill := elapsed * (l.ratePerMinute / 60.0) b.tokens += refill if b.tokens > l.ratePerMinute { b.tokens = l.ratePerMinute } b.lastRefill = now if b.tokens < 1 { return false } b.tokens-- return true } // cleanupLoop periodically evicts buckets idle long enough to have fully // refilled, so a limiter tracking many distinct one-off IPs doesn't grow // unboundedly over a long-running server's lifetime. func (l *Limiter) cleanupLoop() { ticker := time.NewTicker(10 * time.Minute) defer ticker.Stop() for { select { case <-l.stopCleanup: return case <-ticker.C: l.mu.Lock() now := time.Now() for key, b := range l.buckets { if now.Sub(b.lastRefill) > 30*time.Minute { delete(l.buckets, key) } } l.mu.Unlock() } } } // Stop releases the background cleanup goroutine. func (l *Limiter) Stop() { if l.ratePerMinute > 0 { close(l.stopCleanup) } }