limiter/ package (ex03 bucket + ex09 sharded keyed store + idle sweep, race-tested), server/ with /allow /stats /healthz and X-RateLimit headers, client/ demo replica with fail-open/closed and latency percentiles. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GbnJj9ZqEx9nUvzkjdwfMq
129 lines
3.1 KiB
Go
129 lines
3.1 KiB
Go
package limiter
|
|
|
|
import (
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestBurstThenDeny(t *testing.T) {
|
|
s := NewStore(Limit{Rate: 1, Burst: 3}, nil)
|
|
for i := 0; i < 3; i++ {
|
|
if !s.Allow("k", 1).Allowed {
|
|
t.Fatalf("request %d within burst should be allowed", i)
|
|
}
|
|
}
|
|
d := s.Allow("k", 1)
|
|
if d.Allowed {
|
|
t.Fatal("4th immediate request should be denied")
|
|
}
|
|
if d.RetryAfter <= 0 || d.RetryAfter > time.Second {
|
|
t.Fatalf("retry-after should be ~1s at rate 1, got %v", d.RetryAfter)
|
|
}
|
|
}
|
|
|
|
func TestRefill(t *testing.T) {
|
|
s := NewStore(Limit{Rate: 20, Burst: 1}, nil)
|
|
if !s.Allow("k", 1).Allowed {
|
|
t.Fatal("initial token missing")
|
|
}
|
|
if s.Allow("k", 1).Allowed {
|
|
t.Fatal("should be empty immediately after")
|
|
}
|
|
time.Sleep(120 * time.Millisecond)
|
|
if !s.Allow("k", 1).Allowed {
|
|
t.Fatal("should have refilled")
|
|
}
|
|
}
|
|
|
|
func TestBurstIsCapped(t *testing.T) {
|
|
s := NewStore(Limit{Rate: 1000, Burst: 2}, nil)
|
|
time.Sleep(50 * time.Millisecond)
|
|
allowed := 0
|
|
for i := 0; i < 10; i++ {
|
|
if s.Allow("k", 1).Allowed {
|
|
allowed++
|
|
}
|
|
}
|
|
if allowed > 2 {
|
|
t.Fatalf("idle time built credit beyond burst: %d", allowed)
|
|
}
|
|
}
|
|
|
|
func TestKeysAreIsolatedAndOverridden(t *testing.T) {
|
|
s := NewStore(Limit{Rate: 0.0001, Burst: 1}, map[string]Limit{"vip": {Rate: 0.0001, Burst: 5}})
|
|
if !s.Allow("a", 1).Allowed || s.Allow("a", 1).Allowed {
|
|
t.Fatal("key a should allow exactly one")
|
|
}
|
|
if !s.Allow("b", 1).Allowed {
|
|
t.Fatal("key b must have its own bucket")
|
|
}
|
|
for i := 0; i < 5; i++ {
|
|
if !s.Allow("vip", 1).Allowed {
|
|
t.Fatalf("vip request %d should be allowed (burst 5)", i)
|
|
}
|
|
}
|
|
if s.Allow("vip", 1).Allowed {
|
|
t.Fatal("vip 6th should be denied")
|
|
}
|
|
if got := s.Stats().Keys; got != 3 {
|
|
t.Fatalf("expected 3 keys, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestAllowN(t *testing.T) {
|
|
s := NewStore(Limit{Rate: 0.0001, Burst: 10}, nil)
|
|
if !s.Allow("k", 7).Allowed {
|
|
t.Fatal("7 of 10 should be allowed")
|
|
}
|
|
if s.Allow("k", 4).Allowed {
|
|
t.Fatal("4 more (3 left) should be denied")
|
|
}
|
|
if !s.Allow("k", 3).Allowed {
|
|
t.Fatal("exactly 3 left should be allowed")
|
|
}
|
|
}
|
|
|
|
func TestConcurrentNoOverAdmission(t *testing.T) {
|
|
s := NewStore(Limit{Rate: 0.0001, Burst: 100}, nil)
|
|
var allowed atomic.Int64
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 50; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for j := 0; j < 10; j++ {
|
|
if s.Allow("shared", 1).Allowed {
|
|
allowed.Add(1)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
if got := allowed.Load(); got != 100 {
|
|
t.Fatalf("expected exactly 100 admissions, got %d", got)
|
|
}
|
|
st := s.Stats()
|
|
if st.Allowed != 100 || st.Denied != 400 {
|
|
t.Fatalf("stats mismatch: %+v", st)
|
|
}
|
|
}
|
|
|
|
func TestSweepEvictsIdleOnly(t *testing.T) {
|
|
s := NewStore(Limit{Rate: 1000, Burst: 1}, nil)
|
|
s.Allow("old", 1)
|
|
time.Sleep(30 * time.Millisecond)
|
|
s.Allow("fresh", 1)
|
|
if n := s.Sweep(20 * time.Millisecond); n != 1 {
|
|
t.Fatalf("expected 1 evicted, got %d", n)
|
|
}
|
|
if got := s.Stats().Keys; got != 1 {
|
|
t.Fatalf("expected 1 key left, got %d", got)
|
|
}
|
|
// evicted key comes back full, which is what an idle bucket would be anyway
|
|
if !s.Allow("old", 1).Allowed {
|
|
t.Fatal("recreated bucket should start full")
|
|
}
|
|
}
|