Skip to content

Commit 402dac1

Browse files
authored
fix(middleware): make max request limiting atomic (#237)
1 parent a2b908e commit 402dac1

2 files changed

Lines changed: 100 additions & 10 deletions

File tree

internal/middleware/cocrrent.go

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,25 @@ type MaxRequestIface struct {
2727
lock *sync.RWMutex
2828
}
2929

30+
func (m *MaxRequestIface) tryAcquire(max int) bool {
31+
m.lock.Lock()
32+
defer m.lock.Unlock()
33+
34+
if m.current >= max {
35+
return false
36+
}
37+
38+
m.current++
39+
return true
40+
}
41+
42+
func (m *MaxRequestIface) release() {
43+
m.lock.Lock()
44+
defer m.lock.Unlock()
45+
46+
m.current--
47+
}
48+
3049
func MaxRequest(max int) gin.HandlerFunc {
3150
slog.Info("setting max requests", "max", max)
3251
m := &MaxRequestIface{
@@ -35,20 +54,13 @@ func MaxRequest(max int) gin.HandlerFunc {
3554
}
3655

3756
return func(c *gin.Context) {
38-
m.lock.RLock()
39-
if m.current >= max {
40-
m.lock.RUnlock()
57+
if !m.tryAcquire(max) {
4158
c.JSON(http.StatusServiceUnavailable, types.ErrorResponse(-503, "Too many requests"))
4259
c.Abort()
4360
return
4461
}
45-
m.lock.RUnlock()
46-
m.lock.Lock()
47-
m.current++
48-
m.lock.Unlock()
62+
defer m.release()
63+
4964
c.Next()
50-
m.lock.Lock()
51-
m.current--
52-
m.lock.Unlock()
5365
}
5466
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package middleware
2+
3+
import (
4+
"sync"
5+
"testing"
6+
)
7+
8+
func TestMaxRequestTryAcquireRespectsLimitUnderConcurrency(t *testing.T) {
9+
m := &MaxRequestIface{
10+
current: 0,
11+
lock: &sync.RWMutex{},
12+
}
13+
14+
const (
15+
max = 1
16+
goroutines = 32
17+
)
18+
19+
start := make(chan struct{})
20+
results := make(chan bool, goroutines)
21+
22+
var wg sync.WaitGroup
23+
for i := 0; i < goroutines; i++ {
24+
wg.Add(1)
25+
go func() {
26+
defer wg.Done()
27+
<-start
28+
results <- m.tryAcquire(max)
29+
}()
30+
}
31+
32+
close(start)
33+
wg.Wait()
34+
close(results)
35+
36+
successes := 0
37+
failures := 0
38+
for acquired := range results {
39+
if acquired {
40+
successes++
41+
continue
42+
}
43+
failures++
44+
}
45+
46+
if successes != max {
47+
t.Fatalf("expected %d successful acquisition, got %d", max, successes)
48+
}
49+
50+
if failures != goroutines-max {
51+
t.Fatalf("expected %d failed acquisitions, got %d", goroutines-max, failures)
52+
}
53+
54+
if m.current != max {
55+
t.Fatalf("expected current to remain %d, got %d", max, m.current)
56+
}
57+
}
58+
59+
func TestMaxRequestReleaseRestoresCapacity(t *testing.T) {
60+
m := &MaxRequestIface{
61+
current: 0,
62+
lock: &sync.RWMutex{},
63+
}
64+
65+
if !m.tryAcquire(1) {
66+
t.Fatal("expected first acquisition to succeed")
67+
}
68+
69+
if m.tryAcquire(1) {
70+
t.Fatal("expected second acquisition to fail while limit is reached")
71+
}
72+
73+
m.release()
74+
75+
if !m.tryAcquire(1) {
76+
t.Fatal("expected acquisition to succeed after release")
77+
}
78+
}

0 commit comments

Comments
 (0)