From b5c20c78924fba39f66483266d31024db2f5805c Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 14 May 2026 22:22:24 +0200 Subject: [PATCH] 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 --- backend/go.mod | 1 + backend/go.sum | 2 + backend/internal/auth/ratelimit.go | 116 ++++++++++++++++++++ backend/internal/auth/ratelimit_test.go | 136 ++++++++++++++++++++++++ 4 files changed, 255 insertions(+) create mode 100644 backend/internal/auth/ratelimit.go create mode 100644 backend/internal/auth/ratelimit_test.go diff --git a/backend/go.mod b/backend/go.mod index 36b7407..bbf1d23 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 ) diff --git a/backend/go.sum b/backend/go.sum index 349c529..1a8a457 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/auth/ratelimit.go b/backend/internal/auth/ratelimit.go new file mode 100644 index 0000000..7c1a4e3 --- /dev/null +++ b/backend/internal/auth/ratelimit.go @@ -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) +} diff --git a/backend/internal/auth/ratelimit_test.go b/backend/internal/auth/ratelimit_test.go new file mode 100644 index 0000000..d8a6df7 --- /dev/null +++ b/backend/internal/auth/ratelimit_test.go @@ -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() +}