detached c6cd9f4

feat(ratelimit): add memory store implementation with factory

commit
c6cd9f4392c2cc37cd7dd8ddd6b76b13531af9f2
author
bnema · · 2026-01-18 02:14 UTC
parent
101f297
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close