feat(ratelimit): add memory store implementation with factory
- commit
- ab451b37fac517a52be83811221cbdb87aba96b0
- author
- bnema · · 2026-01-18 02:14 UTC
- parent
- b5ca9b5
- signature
- unsigned
4 files · +285 -0
A internal/adapters/out/ratelimit/memory.go +70 -0
| @@ -0,0 +1,70 @@ | |||
| 1 | + | // Package ratelimit provides rate limiter implementations. | |
| 2 | + | package ratelimit | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "sync" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zerowrap" | |
| 10 | + | "golang.org/x/time/rate" | |
| 11 | + | ||
| 12 | + | "gordon/internal/boundaries/out" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // Ensure MemoryStore implements out.RateLimiter. | |
| 16 | + | var _ out.RateLimiter = (*MemoryStore)(nil) | |
| 17 | + | ||
| 18 | + | // MemoryStore is an in-memory rate limiter implementation using golang.org/x/time/rate. | |
| 19 | + | // Each unique key gets its own independent rate limiter. | |
| 20 | + | type MemoryStore struct { | |
| 21 | + | limiters map[string]*rate.Limiter | |
| 22 | + | mu sync.RWMutex | |
| 23 | + | rps float64 | |
| 24 | + | burst int | |
| 25 | + | log zerowrap.Logger | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // NewMemoryStore creates a new in-memory rate limiter store. | |
| 29 | + | func NewMemoryStore(rps float64, burst int, log zerowrap.Logger) *MemoryStore { | |
| 30 | + | return &MemoryStore{ | |
| 31 | + | limiters: make(map[string]*rate.Limiter), | |
| 32 | + | rps: rps, | |
| 33 | + | burst: burst, | |
| 34 | + | log: log, | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // Allow checks if a request identified by key is allowed. | |
| 39 | + | // Returns true if allowed, false if rate limited. | |
| 40 | + | func (s *MemoryStore) Allow(_ context.Context, key string) bool { | |
| 41 | + | return s.getLimiter(key).Allow() | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // AllowN checks if n requests identified by key are allowed. | |
| 45 | + | func (s *MemoryStore) AllowN(_ context.Context, key string, n int) bool { | |
| 46 | + | return s.getLimiter(key).AllowN(time.Now(), n) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // getLimiter returns the rate limiter for the given key, creating one if it doesn't exist. | |
| 50 | + | func (s *MemoryStore) getLimiter(key string) *rate.Limiter { | |
| 51 | + | s.mu.RLock() | |
| 52 | + | limiter, exists := s.limiters[key] | |
| 53 | + | s.mu.RUnlock() | |
| 54 | + | ||
| 55 | + | if exists { | |
| 56 | + | return limiter | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | s.mu.Lock() | |
| 60 | + | defer s.mu.Unlock() | |
| 61 | + | ||
| 62 | + | // Double-check after acquiring write lock | |
| 63 | + | if limiter, exists = s.limiters[key]; exists { | |
| 64 | + | return limiter | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | limiter = rate.NewLimiter(rate.Limit(s.rps), s.burst) | |
| 68 | + | s.limiters[key] = limiter | |
| 69 | + | return limiter | |
| 70 | + | } | |
| @@ -0,0 +1,70 @@ | |||||
| 1 | + | // Package ratelimit provides rate limiter implementations. | |||
| 2 | + | package ratelimit | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "sync" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zerowrap" | |||
| 10 | + | "golang.org/x/time/rate" | |||
| 11 | + | ||||
| 12 | + | "gordon/internal/boundaries/out" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // Ensure MemoryStore implements out.RateLimiter. | |||
| 16 | + | var _ out.RateLimiter = (*MemoryStore)(nil) | |||
| 17 | + | ||||
| 18 | + | // MemoryStore is an in-memory rate limiter implementation using golang.org/x/time/rate. | |||
| 19 | + | // Each unique key gets its own independent rate limiter. | |||
| 20 | + | type MemoryStore struct { | |||
| 21 | + | limiters map[string]*rate.Limiter | |||
| 22 | + | mu sync.RWMutex | |||
| 23 | + | rps float64 | |||
| 24 | + | burst int | |||
| 25 | + | log zerowrap.Logger | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // NewMemoryStore creates a new in-memory rate limiter store. | |||
| 29 | + | func NewMemoryStore(rps float64, burst int, log zerowrap.Logger) *MemoryStore { | |||
| 30 | + | return &MemoryStore{ | |||
| 31 | + | limiters: make(map[string]*rate.Limiter), | |||
| 32 | + | rps: rps, | |||
| 33 | + | burst: burst, | |||
| 34 | + | log: log, | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // Allow checks if a request identified by key is allowed. | |||
| 39 | + | // Returns true if allowed, false if rate limited. | |||
| 40 | + | func (s *MemoryStore) Allow(_ context.Context, key string) bool { | |||
| 41 | + | return s.getLimiter(key).Allow() | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // AllowN checks if n requests identified by key are allowed. | |||
| 45 | + | func (s *MemoryStore) AllowN(_ context.Context, key string, n int) bool { | |||
| 46 | + | return s.getLimiter(key).AllowN(time.Now(), n) | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // getLimiter returns the rate limiter for the given key, creating one if it doesn't exist. | |||
| 50 | + | func (s *MemoryStore) getLimiter(key string) *rate.Limiter { | |||
| 51 | + | s.mu.RLock() | |||
| 52 | + | limiter, exists := s.limiters[key] | |||
| 53 | + | s.mu.RUnlock() | |||
| 54 | + | ||||
| 55 | + | if exists { | |||
| 56 | + | return limiter | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | s.mu.Lock() | |||
| 60 | + | defer s.mu.Unlock() | |||
| 61 | + | ||||
| 62 | + | // Double-check after acquiring write lock | |||
| 63 | + | if limiter, exists = s.limiters[key]; exists { | |||
| 64 | + | return limiter | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | limiter = rate.NewLimiter(rate.Limit(s.rps), s.burst) | |||
| 68 | + | s.limiters[key] = limiter | |||
| 69 | + | return limiter | |||
| 70 | + | } | |||
A internal/adapters/out/ratelimit/memory_test.go +152 -0
| @@ -0,0 +1,152 @@ | |||
| 1 | + | package ratelimit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zerowrap" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func testLogger() zerowrap.Logger { | |
| 15 | + | return zerowrap.Default() | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func TestMemoryStore_Allow_WithinLimit(t *testing.T) { | |
| 19 | + | store := NewMemoryStore(10, 10, testLogger()) // 10 RPS, burst 10 | |
| 20 | + | ctx := context.Background() | |
| 21 | + | ||
| 22 | + | // Should allow requests up to burst | |
| 23 | + | for i := 0; i < 10; i++ { | |
| 24 | + | assert.True(t, store.Allow(ctx, "test"), "request %d should be allowed", i+1) | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestMemoryStore_Allow_ExceedsLimit(t *testing.T) { | |
| 29 | + | store := NewMemoryStore(1, 1, testLogger()) // 1 RPS, burst 1 | |
| 30 | + | ctx := context.Background() | |
| 31 | + | ||
| 32 | + | // First request should succeed (consumes the burst) | |
| 33 | + | assert.True(t, store.Allow(ctx, "test"), "first request should be allowed") | |
| 34 | + | ||
| 35 | + | // Second request should be rate limited | |
| 36 | + | assert.False(t, store.Allow(ctx, "test"), "second request should be rate limited") | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestMemoryStore_Allow_BurstBehavior(t *testing.T) { | |
| 40 | + | store := NewMemoryStore(10, 5, testLogger()) // 10 RPS, burst 5 | |
| 41 | + | ctx := context.Background() | |
| 42 | + | ||
| 43 | + | // Should allow burst of 5 | |
| 44 | + | for i := 0; i < 5; i++ { | |
| 45 | + | assert.True(t, store.Allow(ctx, "test"), "burst request %d should be allowed", i+1) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // 6th request should be rate limited (burst exhausted) | |
| 49 | + | assert.False(t, store.Allow(ctx, "test"), "request exceeding burst should be rate limited") | |
| 50 | + | ||
| 51 | + | // Wait for token replenishment | |
| 52 | + | time.Sleep(200 * time.Millisecond) | |
| 53 | + | ||
| 54 | + | // Should be able to make at least one request after waiting | |
| 55 | + | assert.True(t, store.Allow(ctx, "test"), "request after waiting should be allowed") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestMemoryStore_Allow_IndependentKeys(t *testing.T) { | |
| 59 | + | store := NewMemoryStore(1, 1, testLogger()) // 1 RPS, burst 1 | |
| 60 | + | ctx := context.Background() | |
| 61 | + | ||
| 62 | + | // First request for key1 should succeed | |
| 63 | + | assert.True(t, store.Allow(ctx, "key1"), "first request for key1 should be allowed") | |
| 64 | + | ||
| 65 | + | // Key1 is now rate limited | |
| 66 | + | assert.False(t, store.Allow(ctx, "key1"), "second request for key1 should be rate limited") | |
| 67 | + | ||
| 68 | + | // Key2 should have its own independent limit | |
| 69 | + | assert.True(t, store.Allow(ctx, "key2"), "first request for key2 should be allowed") | |
| 70 | + | ||
| 71 | + | // Key2 is now rate limited | |
| 72 | + | assert.False(t, store.Allow(ctx, "key2"), "second request for key2 should be rate limited") | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestMemoryStore_AllowN_Basic(t *testing.T) { | |
| 76 | + | store := NewMemoryStore(10, 10, testLogger()) // 10 RPS, burst 10 | |
| 77 | + | ctx := context.Background() | |
| 78 | + | ||
| 79 | + | // Should allow 5 tokens at once | |
| 80 | + | assert.True(t, store.AllowN(ctx, "test", 5), "AllowN(5) should succeed with burst of 10") | |
| 81 | + | ||
| 82 | + | // Should allow another 5 | |
| 83 | + | assert.True(t, store.AllowN(ctx, "test", 5), "AllowN(5) should succeed with 5 remaining") | |
| 84 | + | ||
| 85 | + | // Should not allow more | |
| 86 | + | assert.False(t, store.AllowN(ctx, "test", 1), "AllowN(1) should fail with 0 remaining") | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestMemoryStore_AllowN_ExceedsBurst(t *testing.T) { | |
| 90 | + | store := NewMemoryStore(10, 5, testLogger()) // 10 RPS, burst 5 | |
| 91 | + | ctx := context.Background() | |
| 92 | + | ||
| 93 | + | // Should not allow more than burst | |
| 94 | + | assert.False(t, store.AllowN(ctx, "test", 10), "AllowN(10) should fail with burst of 5") | |
| 95 | + | ||
| 96 | + | // But smaller request should work | |
| 97 | + | assert.True(t, store.AllowN(ctx, "test", 3), "AllowN(3) should succeed with burst of 5") | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestMemoryStore_Allow_ConcurrentAccess(t *testing.T) { | |
| 101 | + | store := NewMemoryStore(1000, 100, testLogger()) // High RPS for concurrent testing | |
| 102 | + | ctx := context.Background() | |
| 103 | + | ||
| 104 | + | var wg sync.WaitGroup | |
| 105 | + | results := make(chan bool, 200) | |
| 106 | + | ||
| 107 | + | // Run 20 goroutines, each making 10 requests | |
| 108 | + | for i := 0; i < 20; i++ { | |
| 109 | + | wg.Add(1) | |
| 110 | + | go func() { | |
| 111 | + | defer wg.Done() | |
| 112 | + | for j := 0; j < 10; j++ { | |
| 113 | + | results <- store.Allow(ctx, "concurrent") | |
| 114 | + | } | |
| 115 | + | }() | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | wg.Wait() | |
| 119 | + | close(results) | |
| 120 | + | ||
| 121 | + | // Count successful requests | |
| 122 | + | allowed := 0 | |
| 123 | + | for result := range results { | |
| 124 | + | if result { | |
| 125 | + | allowed++ | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // At least burst number should be allowed | |
| 130 | + | require.GreaterOrEqual(t, allowed, 100, "at least burst number of requests should be allowed") | |
| 131 | + | // And we shouldn't exceed total requests | |
| 132 | + | require.LessOrEqual(t, allowed, 200, "should not exceed total requests") | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func TestMemoryStore_IPKeyFormat(t *testing.T) { | |
| 136 | + | store := NewMemoryStore(1, 1, testLogger()) | |
| 137 | + | ctx := context.Background() | |
| 138 | + | ||
| 139 | + | // Test typical IP key formats | |
| 140 | + | assert.True(t, store.Allow(ctx, "ip:192.168.1.1"), "should allow first request for IP") | |
| 141 | + | assert.False(t, store.Allow(ctx, "ip:192.168.1.1"), "should rate limit same IP") | |
| 142 | + | assert.True(t, store.Allow(ctx, "ip:192.168.1.2"), "should allow first request for different IP") | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func TestMemoryStore_GlobalKey(t *testing.T) { | |
| 146 | + | store := NewMemoryStore(1, 1, testLogger()) | |
| 147 | + | ctx := context.Background() | |
| 148 | + | ||
| 149 | + | // Test global key | |
| 150 | + | assert.True(t, store.Allow(ctx, "global"), "should allow first global request") | |
| 151 | + | assert.False(t, store.Allow(ctx, "global"), "should rate limit global requests") | |
| 152 | + | } | |
| @@ -0,0 +1,152 @@ | |||||
| 1 | + | package ratelimit | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sync" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zerowrap" | |||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func testLogger() zerowrap.Logger { | |||
| 15 | + | return zerowrap.Default() | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func TestMemoryStore_Allow_WithinLimit(t *testing.T) { | |||
| 19 | + | store := NewMemoryStore(10, 10, testLogger()) // 10 RPS, burst 10 | |||
| 20 | + | ctx := context.Background() | |||
| 21 | + | ||||
| 22 | + | // Should allow requests up to burst | |||
| 23 | + | for i := 0; i < 10; i++ { | |||
| 24 | + | assert.True(t, store.Allow(ctx, "test"), "request %d should be allowed", i+1) | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestMemoryStore_Allow_ExceedsLimit(t *testing.T) { | |||
| 29 | + | store := NewMemoryStore(1, 1, testLogger()) // 1 RPS, burst 1 | |||
| 30 | + | ctx := context.Background() | |||
| 31 | + | ||||
| 32 | + | // First request should succeed (consumes the burst) | |||
| 33 | + | assert.True(t, store.Allow(ctx, "test"), "first request should be allowed") | |||
| 34 | + | ||||
| 35 | + | // Second request should be rate limited | |||
| 36 | + | assert.False(t, store.Allow(ctx, "test"), "second request should be rate limited") | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestMemoryStore_Allow_BurstBehavior(t *testing.T) { | |||
| 40 | + | store := NewMemoryStore(10, 5, testLogger()) // 10 RPS, burst 5 | |||
| 41 | + | ctx := context.Background() | |||
| 42 | + | ||||
| 43 | + | // Should allow burst of 5 | |||
| 44 | + | for i := 0; i < 5; i++ { | |||
| 45 | + | assert.True(t, store.Allow(ctx, "test"), "burst request %d should be allowed", i+1) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // 6th request should be rate limited (burst exhausted) | |||
| 49 | + | assert.False(t, store.Allow(ctx, "test"), "request exceeding burst should be rate limited") | |||
| 50 | + | ||||
| 51 | + | // Wait for token replenishment | |||
| 52 | + | time.Sleep(200 * time.Millisecond) | |||
| 53 | + | ||||
| 54 | + | // Should be able to make at least one request after waiting | |||
| 55 | + | assert.True(t, store.Allow(ctx, "test"), "request after waiting should be allowed") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestMemoryStore_Allow_IndependentKeys(t *testing.T) { | |||
| 59 | + | store := NewMemoryStore(1, 1, testLogger()) // 1 RPS, burst 1 | |||
| 60 | + | ctx := context.Background() | |||
| 61 | + | ||||
| 62 | + | // First request for key1 should succeed | |||
| 63 | + | assert.True(t, store.Allow(ctx, "key1"), "first request for key1 should be allowed") | |||
| 64 | + | ||||
| 65 | + | // Key1 is now rate limited | |||
| 66 | + | assert.False(t, store.Allow(ctx, "key1"), "second request for key1 should be rate limited") | |||
| 67 | + | ||||
| 68 | + | // Key2 should have its own independent limit | |||
| 69 | + | assert.True(t, store.Allow(ctx, "key2"), "first request for key2 should be allowed") | |||
| 70 | + | ||||
| 71 | + | // Key2 is now rate limited | |||
| 72 | + | assert.False(t, store.Allow(ctx, "key2"), "second request for key2 should be rate limited") | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestMemoryStore_AllowN_Basic(t *testing.T) { | |||
| 76 | + | store := NewMemoryStore(10, 10, testLogger()) // 10 RPS, burst 10 | |||
| 77 | + | ctx := context.Background() | |||
| 78 | + | ||||
| 79 | + | // Should allow 5 tokens at once | |||
| 80 | + | assert.True(t, store.AllowN(ctx, "test", 5), "AllowN(5) should succeed with burst of 10") | |||
| 81 | + | ||||
| 82 | + | // Should allow another 5 | |||
| 83 | + | assert.True(t, store.AllowN(ctx, "test", 5), "AllowN(5) should succeed with 5 remaining") | |||
| 84 | + | ||||
| 85 | + | // Should not allow more | |||
| 86 | + | assert.False(t, store.AllowN(ctx, "test", 1), "AllowN(1) should fail with 0 remaining") | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestMemoryStore_AllowN_ExceedsBurst(t *testing.T) { | |||
| 90 | + | store := NewMemoryStore(10, 5, testLogger()) // 10 RPS, burst 5 | |||
| 91 | + | ctx := context.Background() | |||
| 92 | + | ||||
| 93 | + | // Should not allow more than burst | |||
| 94 | + | assert.False(t, store.AllowN(ctx, "test", 10), "AllowN(10) should fail with burst of 5") | |||
| 95 | + | ||||
| 96 | + | // But smaller request should work | |||
| 97 | + | assert.True(t, store.AllowN(ctx, "test", 3), "AllowN(3) should succeed with burst of 5") | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func TestMemoryStore_Allow_ConcurrentAccess(t *testing.T) { | |||
| 101 | + | store := NewMemoryStore(1000, 100, testLogger()) // High RPS for concurrent testing | |||
| 102 | + | ctx := context.Background() | |||
| 103 | + | ||||
| 104 | + | var wg sync.WaitGroup | |||
| 105 | + | results := make(chan bool, 200) | |||
| 106 | + | ||||
| 107 | + | // Run 20 goroutines, each making 10 requests | |||
| 108 | + | for i := 0; i < 20; i++ { | |||
| 109 | + | wg.Add(1) | |||
| 110 | + | go func() { | |||
| 111 | + | defer wg.Done() | |||
| 112 | + | for j := 0; j < 10; j++ { | |||
| 113 | + | results <- store.Allow(ctx, "concurrent") | |||
| 114 | + | } | |||
| 115 | + | }() | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | wg.Wait() | |||
| 119 | + | close(results) | |||
| 120 | + | ||||
| 121 | + | // Count successful requests | |||
| 122 | + | allowed := 0 | |||
| 123 | + | for result := range results { | |||
| 124 | + | if result { | |||
| 125 | + | allowed++ | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | // At least burst number should be allowed | |||
| 130 | + | require.GreaterOrEqual(t, allowed, 100, "at least burst number of requests should be allowed") | |||
| 131 | + | // And we shouldn't exceed total requests | |||
| 132 | + | require.LessOrEqual(t, allowed, 200, "should not exceed total requests") | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func TestMemoryStore_IPKeyFormat(t *testing.T) { | |||
| 136 | + | store := NewMemoryStore(1, 1, testLogger()) | |||
| 137 | + | ctx := context.Background() | |||
| 138 | + | ||||
| 139 | + | // Test typical IP key formats | |||
| 140 | + | assert.True(t, store.Allow(ctx, "ip:192.168.1.1"), "should allow first request for IP") | |||
| 141 | + | assert.False(t, store.Allow(ctx, "ip:192.168.1.1"), "should rate limit same IP") | |||
| 142 | + | assert.True(t, store.Allow(ctx, "ip:192.168.1.2"), "should allow first request for different IP") | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func TestMemoryStore_GlobalKey(t *testing.T) { | |||
| 146 | + | store := NewMemoryStore(1, 1, testLogger()) | |||
| 147 | + | ctx := context.Background() | |||
| 148 | + | ||||
| 149 | + | // Test global key | |||
| 150 | + | assert.True(t, store.Allow(ctx, "global"), "should allow first global request") | |||
| 151 | + | assert.False(t, store.Allow(ctx, "global"), "should rate limit global requests") | |||
| 152 | + | } | |||
A internal/adapters/out/ratelimit/store.go +21 -0
| @@ -0,0 +1,21 @@ | |||
| 1 | + | package ratelimit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/zerowrap" | |
| 7 | + | ||
| 8 | + | "gordon/internal/boundaries/out" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // NewStore creates a RateLimiter based on the configured backend. | |
| 12 | + | func NewStore(backend string, rps float64, burst int, log zerowrap.Logger) (out.RateLimiter, error) { | |
| 13 | + | switch backend { | |
| 14 | + | case "memory", "": | |
| 15 | + | return NewMemoryStore(rps, burst, log), nil | |
| 16 | + | case "redis": | |
| 17 | + | return nil, fmt.Errorf("redis backend not yet implemented") | |
| 18 | + | default: | |
| 19 | + | return nil, fmt.Errorf("unknown rate limit backend: %s", backend) | |
| 20 | + | } | |
| 21 | + | } | |
| @@ -0,0 +1,21 @@ | |||||
| 1 | + | package ratelimit | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/zerowrap" | |||
| 7 | + | ||||
| 8 | + | "gordon/internal/boundaries/out" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // NewStore creates a RateLimiter based on the configured backend. | |||
| 12 | + | func NewStore(backend string, rps float64, burst int, log zerowrap.Logger) (out.RateLimiter, error) { | |||
| 13 | + | switch backend { | |||
| 14 | + | case "memory", "": | |||
| 15 | + | return NewMemoryStore(rps, burst, log), nil | |||
| 16 | + | case "redis": | |||
| 17 | + | return nil, fmt.Errorf("redis backend not yet implemented") | |||
| 18 | + | default: | |||
| 19 | + | return nil, fmt.Errorf("unknown rate limit backend: %s", backend) | |||
| 20 | + | } | |||
| 21 | + | } | |||
A internal/adapters/out/ratelimit/store_test.go +42 -0
| @@ -0,0 +1,42 @@ | |||
| 1 | + | package ratelimit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestNewStore_MemoryBackend(t *testing.T) { | |
| 11 | + | store, err := NewStore("memory", 10, 5, testLogger()) | |
| 12 | + | require.NoError(t, err) | |
| 13 | + | require.NotNil(t, store) | |
| 14 | + | ||
| 15 | + | // Verify it's a MemoryStore | |
| 16 | + | _, ok := store.(*MemoryStore) | |
| 17 | + | assert.True(t, ok, "should create a MemoryStore") | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func TestNewStore_EmptyBackend(t *testing.T) { | |
| 21 | + | store, err := NewStore("", 10, 5, testLogger()) | |
| 22 | + | require.NoError(t, err) | |
| 23 | + | require.NotNil(t, store) | |
| 24 | + | ||
| 25 | + | // Empty backend should default to memory | |
| 26 | + | _, ok := store.(*MemoryStore) | |
| 27 | + | assert.True(t, ok, "empty backend should create a MemoryStore") | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestNewStore_RedisBackend(t *testing.T) { | |
| 31 | + | store, err := NewStore("redis", 10, 5, testLogger()) | |
| 32 | + | assert.Error(t, err) | |
| 33 | + | assert.Nil(t, store) | |
| 34 | + | assert.Contains(t, err.Error(), "not yet implemented") | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestNewStore_UnknownBackend(t *testing.T) { | |
| 38 | + | store, err := NewStore("postgres", 10, 5, testLogger()) | |
| 39 | + | assert.Error(t, err) | |
| 40 | + | assert.Nil(t, store) | |
| 41 | + | assert.Contains(t, err.Error(), "unknown rate limit backend") | |
| 42 | + | } | |
| @@ -0,0 +1,42 @@ | |||||
| 1 | + | package ratelimit | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestNewStore_MemoryBackend(t *testing.T) { | |||
| 11 | + | store, err := NewStore("memory", 10, 5, testLogger()) | |||
| 12 | + | require.NoError(t, err) | |||
| 13 | + | require.NotNil(t, store) | |||
| 14 | + | ||||
| 15 | + | // Verify it's a MemoryStore | |||
| 16 | + | _, ok := store.(*MemoryStore) | |||
| 17 | + | assert.True(t, ok, "should create a MemoryStore") | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func TestNewStore_EmptyBackend(t *testing.T) { | |||
| 21 | + | store, err := NewStore("", 10, 5, testLogger()) | |||
| 22 | + | require.NoError(t, err) | |||
| 23 | + | require.NotNil(t, store) | |||
| 24 | + | ||||
| 25 | + | // Empty backend should default to memory | |||
| 26 | + | _, ok := store.(*MemoryStore) | |||
| 27 | + | assert.True(t, ok, "empty backend should create a MemoryStore") | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestNewStore_RedisBackend(t *testing.T) { | |||
| 31 | + | store, err := NewStore("redis", 10, 5, testLogger()) | |||
| 32 | + | assert.Error(t, err) | |||
| 33 | + | assert.Nil(t, store) | |||
| 34 | + | assert.Contains(t, err.Error(), "not yet implemented") | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestNewStore_UnknownBackend(t *testing.T) { | |||
| 38 | + | store, err := NewStore("postgres", 10, 5, testLogger()) | |||
| 39 | + | assert.Error(t, err) | |||
| 40 | + | assert.Nil(t, store) | |||
| 41 | + | assert.Contains(t, err.Error(), "unknown rate limit backend") | |||
| 42 | + | } | |||