package ratelimiter import ( "sync" "testing" "time" ) // TokenBucket is a thread-safe token-bucket rate limiter. type TokenBucket struct { mu sync.Mutex rate float64 capacity float64 tokens float64 last time.Time } // NewTokenBucket creates a limiter that refills rate tokens per second, up to capacity. func NewTokenBucket(rate float64, capacity int) *TokenBucket { if rate <= 0 { panic("rate must be positive") } if capacity <= 0 { panic("capacity must be positive") } now := time.Now() return &TokenBucket{ rate: rate, capacity: float64(capacity), tokens: float64(capacity), last: now, } } // Allow reports whether one token is available and consumes it if so. func (b *TokenBucket) Allow() bool { return b.AllowN(1) } // AllowN reports whether n tokens are available and consumes them if so. func (b *TokenBucket) AllowN(n int) bool { if n <= 0 { return true } b.mu.Lock() defer b.mu.Unlock() b.refill(time.Now()) need := float64(n) if need > b.tokens { return false } b.tokens -= need return true } // Tokens returns the current number of available whole tokens. func (b *TokenBucket) Tokens() int { b.mu.Lock() defer b.mu.Unlock() b.refill(time.Now()) return int(b.tokens) } func (b *TokenBucket) refill(now time.Time) { elapsed := now.Sub(b.last).Seconds() if elapsed <= 0 { return } b.tokens += elapsed * b.rate if b.tokens > b.capacity { b.tokens = b.capacity } b.last = now } func TestTokenBucketConsumesCapacity(t *testing.T) { b := NewTokenBucket(10, 2) if !b.Allow() { t.Fatal("expected first token to be allowed") } if !b.Allow() { t.Fatal("expected second token to be allowed") } if b.Allow() { t.Fatal("expected empty bucket to reject request") } } func TestTokenBucketRefills(t *testing.T) { b := NewTokenBucket(100, 1) if !b.Allow() { t.Fatal("expected initial token to be allowed") } if b.Allow() { t.Fatal("expected empty bucket to reject request") } time.Sleep(15 * time.Millisecond) if !b.Allow() { t.Fatal("expected bucket to refill") } }