Files
2026-08-09 18:03:09 +01:00

110 lines
2.8 KiB
Go

// 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)
}
}