feat(02-05): implement LimiterStore with injectable clock and janitor
- Token-bucket rate limiter keyed per (email+IP) using golang.org/x/time/rate - rate.Every(12s), burst=5, idleTTL=10min (D-16) - AllowN(t, 1) with injectable clock for deterministic tests (Pattern 8) - Janitor goroutine evicts entries idle > 10min via cleanupNow() - No .Allow() without args (Pitfall 8 avoided) - Five tests pass with -race: burst, refill, isolation, janitor, concurrent - golang.org/x/time v0.15.0 added to go.mod
This commit is contained in:
parent
f6a453ff1f
commit
b5c20c7892
4 changed files with 255 additions and 0 deletions
|
|
@ -21,4 +21,5 @@ require (
|
|||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
)
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
|||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
|
|
|||
116
backend/internal/auth/ratelimit.go
Normal file
116
backend/internal/auth/ratelimit.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// entry holds a per-key token-bucket limiter and the last time the key was
|
||||
// accessed. lastSeen is used by the janitor to evict idle entries (D-16).
|
||||
type entry struct {
|
||||
lim *rate.Limiter
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
// LimiterStore is a map of token-bucket rate limiters keyed on an arbitrary
|
||||
// string (e.g. lower(email)+":"+clientIP). Each key gets an independent
|
||||
// Limiter so exhausting one key never affects another (D-16).
|
||||
//
|
||||
// The store uses an injectable clock (now) so unit tests can control time
|
||||
// deterministically without sleeps (Pattern 8). In production, now is time.Now.
|
||||
//
|
||||
// Memory is bounded: a janitor goroutine (StartJanitor) removes entries idle
|
||||
// longer than idleTTL, preventing unlimited growth in high-cardinality key
|
||||
// spaces (D-16, Pitfall 11).
|
||||
type LimiterStore struct {
|
||||
mu sync.Mutex
|
||||
limits map[string]*entry
|
||||
r rate.Limit
|
||||
burst int
|
||||
idleTTL time.Duration
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewLimiterStore returns a LimiterStore configured for 5 requests per minute
|
||||
// (rate.Every(12s)), burst=5, idleTTL=10min. The clock defaults to time.Now.
|
||||
//
|
||||
// Start the janitor with StartJanitor before the store is used in production
|
||||
// to prevent unbounded memory growth.
|
||||
func NewLimiterStore() *LimiterStore {
|
||||
return &LimiterStore{
|
||||
limits: make(map[string]*entry),
|
||||
r: rate.Every(12 * time.Second), // 5 tokens/min
|
||||
burst: 5,
|
||||
idleTTL: 10 * time.Minute,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// newLimiterStoreWithClock creates a LimiterStore with an injectable clock.
|
||||
// Used in tests to drive time deterministically (Pattern 8).
|
||||
func newLimiterStoreWithClock(now func() time.Time) *LimiterStore {
|
||||
s := NewLimiterStore()
|
||||
s.now = now
|
||||
return s
|
||||
}
|
||||
|
||||
// Allow reports whether the key has a token available. It uses AllowN(t, 1)
|
||||
// with the injectable clock (not Allow() which uses wall time internally and
|
||||
// is untestable — Pitfall 8 / Pattern 8).
|
||||
//
|
||||
// Allow is safe for concurrent use.
|
||||
func (s *LimiterStore) Allow(key string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
e, ok := s.limits[key]
|
||||
if !ok {
|
||||
e = &entry{lim: rate.NewLimiter(s.r, s.burst)}
|
||||
s.limits[key] = e
|
||||
}
|
||||
t := s.now()
|
||||
e.lastSeen = t
|
||||
return e.lim.AllowN(t, 1)
|
||||
}
|
||||
|
||||
// StartJanitor launches a background goroutine that calls cleanupNow on each
|
||||
// tick. Send to stop to shut the goroutine down cleanly (e.g. on server
|
||||
// shutdown). interval is typically 1 minute in production.
|
||||
func (s *LimiterStore) StartJanitor(interval time.Duration, stop <-chan struct{}) {
|
||||
go func() {
|
||||
tick := time.NewTicker(interval)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-tick.C:
|
||||
s.cleanupNow()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// cleanupNow removes all entries whose lastSeen is older than idleTTL.
|
||||
// It is called by the janitor goroutine and is also exported for direct use
|
||||
// in tests (same package).
|
||||
func (s *LimiterStore) cleanupNow() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
cutoff := s.now().Add(-s.idleTTL)
|
||||
for k, e := range s.limits {
|
||||
if e.lastSeen.Before(cutoff) {
|
||||
delete(s.limits, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// size returns the number of tracked keys. Used only in tests.
|
||||
func (s *LimiterStore) size() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.limits)
|
||||
}
|
||||
136
backend/internal/auth/ratelimit_test.go
Normal file
136
backend/internal/auth/ratelimit_test.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeNow returns a function that returns a fixed time, advancing by delta on
|
||||
// each call. For tests that need a static clock, pass delta=0.
|
||||
func fakeNow(t0 time.Time, delta time.Duration) func() time.Time {
|
||||
mu := sync.Mutex{}
|
||||
current := t0
|
||||
return func() time.Time {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
t := current
|
||||
current = current.Add(delta)
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
// staticNow returns a function that always returns the same time.
|
||||
func staticNow(t0 time.Time) func() time.Time {
|
||||
return func() time.Time { return t0 }
|
||||
}
|
||||
|
||||
// TestRateLimit_BurstAllowsFiveThenDenies verifies that with rate=5/min and
|
||||
// burst=5, exactly five consecutive Allow("k") calls return true at the same
|
||||
// fake timestamp, and the sixth returns false (Pattern 8).
|
||||
func TestRateLimit_BurstAllowsFiveThenDenies(t *testing.T) {
|
||||
t0 := time.Now()
|
||||
s := newLimiterStoreWithClock(staticNow(t0))
|
||||
|
||||
const key = "user@example.com:127.0.0.1"
|
||||
for i := 1; i <= 5; i++ {
|
||||
if !s.Allow(key) {
|
||||
t.Fatalf("call %d: expected true (within burst), got false", i)
|
||||
}
|
||||
}
|
||||
if s.Allow(key) {
|
||||
t.Fatal("call 6: expected false (burst exhausted), got true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimit_RefillsAfter12s checks that after the burst is exhausted, a
|
||||
// new token refills after ~12 seconds (rate.Every(12s) = 5/min).
|
||||
func TestRateLimit_RefillsAfter12s(t *testing.T) {
|
||||
t0 := time.Now()
|
||||
s := newLimiterStoreWithClock(staticNow(t0))
|
||||
|
||||
const key = "user@example.com:127.0.0.1"
|
||||
// Exhaust the burst.
|
||||
for i := 0; i < 5; i++ {
|
||||
s.Allow(key)
|
||||
}
|
||||
// Sixth attempt at t0 must fail.
|
||||
if s.Allow(key) {
|
||||
t.Fatal("expected false immediately after burst exhaustion, got true")
|
||||
}
|
||||
|
||||
// Advance clock by 12 seconds — one token should have refilled.
|
||||
t1 := t0.Add(12 * time.Second)
|
||||
s.now = staticNow(t1)
|
||||
|
||||
if !s.Allow(key) {
|
||||
t.Fatal("expected true after 12s refill window, got false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimit_PerKeyIsolation ensures that exhausting one key does not affect
|
||||
// another key sharing the same IP.
|
||||
func TestRateLimit_PerKeyIsolation(t *testing.T) {
|
||||
t0 := time.Now()
|
||||
s := newLimiterStoreWithClock(staticNow(t0))
|
||||
|
||||
keyA := "alice@example.com:10.0.0.1"
|
||||
keyB := "bob@example.com:10.0.0.1"
|
||||
|
||||
// Exhaust key A.
|
||||
for i := 0; i < 5; i++ {
|
||||
s.Allow(keyA)
|
||||
}
|
||||
if s.Allow(keyA) {
|
||||
t.Fatal("keyA: expected false after burst exhaustion, got true")
|
||||
}
|
||||
|
||||
// Key B must still have its full burst available.
|
||||
if !s.Allow(keyB) {
|
||||
t.Fatal("keyB: expected true (isolated limiter), got false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimit_JanitorEvictsIdle verifies that cleanupNow removes entries
|
||||
// whose lastSeen is older than idleTTL.
|
||||
func TestRateLimit_JanitorEvictsIdle(t *testing.T) {
|
||||
t0 := time.Now()
|
||||
s := newLimiterStoreWithClock(staticNow(t0))
|
||||
|
||||
// Insert two entries by calling Allow.
|
||||
s.Allow("keyA:1.2.3.4")
|
||||
s.Allow("keyB:1.2.3.4")
|
||||
|
||||
if sz := s.size(); sz != 2 {
|
||||
t.Fatalf("before cleanup: size = %d; want 2", sz)
|
||||
}
|
||||
|
||||
// Advance clock past idleTTL (10 min default).
|
||||
t1 := t0.Add(11 * time.Minute)
|
||||
s.now = staticNow(t1)
|
||||
|
||||
s.cleanupNow()
|
||||
|
||||
if sz := s.size(); sz != 0 {
|
||||
t.Fatalf("after cleanup: size = %d; want 0", sz)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimit_ConcurrentAllowDoesNotPanic runs 100 goroutines calling Allow
|
||||
// on overlapping keys concurrently. Run with -race to detect data races.
|
||||
func TestRateLimit_ConcurrentAllowDoesNotPanic(t *testing.T) {
|
||||
t0 := time.Now()
|
||||
s := newLimiterStoreWithClock(staticNow(t0))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
// Three distinct keys with overlapping access.
|
||||
keys := []string{"k1:ip1", "k2:ip1", "k1:ip2"}
|
||||
s.Allow(keys[n%3])
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
Loading…
Reference in a new issue