Skip to content

Commit 9be9e44

Browse files
authored
Merge pull request #9 from StudioLambda/fix/rate-limit-memory-eviction
Fix rate limiter memory leak and update defaults
2 parents ab781d3 + c576cab commit 9be9e44

4 files changed

Lines changed: 339 additions & 29 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ Available in `framework/middleware`:
133133
- `CSRF(origins...)` / `CSRFWith(csrf, problem)` — cross-origin protection
134134
- `CORS(CORSOptions)` — configurable CORS headers
135135
- `SecureHeaders()` / `SecureHeadersWith(opts)` — security headers (HSTS, CSP, X-Frame-Options)
136-
- `RateLimit()` / `RateLimitWith(opts)` — per-key token bucket (default 10 req/s, burst 20)
136+
- `RateLimit()` / `RateLimitWith(opts)` — per-key token bucket (default 15 req/s, burst 30, idle eviction after 5m)
137137
- `Provide(key, value)` / `ProvideWith(fn)` — context injection
138138
- `HTTP(func(http.Handler) http.Handler)` — stdlib middleware adapter
139139

framework/middleware/rate_limit.go

Lines changed: 133 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package middleware
33
import (
44
"net/http"
55
"sync"
6+
"time"
67

78
"github.com/studiolambda/cosmos/framework"
89
"github.com/studiolambda/cosmos/problem"
@@ -21,11 +22,11 @@ var ErrRateLimited = problem.Problem{
2122
// RateLimitOptions configures the rate limiter middleware.
2223
type RateLimitOptions struct {
2324
// RequestsPerSecond is the sustained request rate allowed
24-
// per key (typically per IP). Defaults to 10.
25+
// per key (typically per IP). Defaults to 15.
2526
RequestsPerSecond float64
2627

2728
// Burst is the maximum number of requests allowed in a
28-
// single burst above the sustained rate. Defaults to 20.
29+
// single burst above the sustained rate. Defaults to 30.
2930
Burst int
3031

3132
// KeyFunc extracts the rate-limit key from a request.
@@ -35,17 +36,28 @@ type RateLimitOptions struct {
3536
// ErrorResponse is the problem returned when a request is
3637
// rate-limited. Defaults to [ErrRateLimited].
3738
ErrorResponse problem.Problem
39+
40+
// CleanupInterval is how often the registry sweeps for
41+
// idle entries. Defaults to 1 minute.
42+
CleanupInterval time.Duration
43+
44+
// MaxIdleTime is how long an entry can be idle before
45+
// being evicted. Defaults to 5 minutes.
46+
MaxIdleTime time.Duration
3847
}
3948

40-
// DefaultRateLimitOptions holds sensible defaults: 10 req/s
41-
// sustained with a burst of 20, keyed by remote address.
49+
// DefaultRateLimitOptions holds sensible defaults: 15 req/s
50+
// sustained with a burst of 30, keyed by remote address.
51+
// Idle entries are evicted after 5 minutes of inactivity.
4252
var DefaultRateLimitOptions = RateLimitOptions{
43-
RequestsPerSecond: 10,
44-
Burst: 20,
53+
RequestsPerSecond: 15,
54+
Burst: 30,
4555
KeyFunc: func(r *http.Request) string {
4656
return r.RemoteAddr
4757
},
48-
ErrorResponse: ErrRateLimited,
58+
ErrorResponse: ErrRateLimited,
59+
CleanupInterval: 1 * time.Minute,
60+
MaxIdleTime: 5 * time.Minute,
4961
}
5062

5163
// withDefaults returns a copy of the options with zero values
@@ -67,18 +79,38 @@ func (options RateLimitOptions) withDefaults() RateLimitOptions {
6779
options.ErrorResponse = DefaultRateLimitOptions.ErrorResponse
6880
}
6981

82+
if options.CleanupInterval == 0 {
83+
options.CleanupInterval = DefaultRateLimitOptions.CleanupInterval
84+
}
85+
86+
if options.MaxIdleTime == 0 {
87+
options.MaxIdleTime = DefaultRateLimitOptions.MaxIdleTime
88+
}
89+
7090
return options
7191
}
7292

73-
// rateLimitRegistry manages per-key token bucket rate limiters.
74-
// Each unique key gets its own [rate.Limiter] instance, created
75-
// on first access and reused for subsequent requests.
93+
// rateLimitEntry pairs a token bucket limiter with the time it
94+
// was last accessed. Entries idle longer than [RateLimitOptions.MaxIdleTime]
95+
// are evicted by the cleanup goroutine.
96+
type rateLimitEntry struct {
97+
limiter *rate.Limiter
98+
lastSeen time.Time
99+
}
100+
101+
// rateLimitRegistry manages per-key token bucket rate limiters
102+
// with automatic eviction of idle entries. Each unique key gets
103+
// its own [rate.Limiter] instance, created on first access and
104+
// reused for subsequent requests. A background goroutine
105+
// periodically removes entries that have been idle longer than
106+
// the configured maximum idle time.
76107
type rateLimitRegistry struct {
77-
// mu protects concurrent access to the limiters map.
108+
// mu protects concurrent access to the entries map.
78109
mu sync.Mutex
79110

80-
// limiters maps rate-limit keys to their token bucket.
81-
limiters map[string]*rate.Limiter
111+
// entries maps rate-limit keys to their limiter and
112+
// last-seen timestamp.
113+
entries map[string]*rateLimitEntry
82114

83115
// rps is the sustained requests-per-second rate for
84116
// newly created limiters.
@@ -87,46 +119,120 @@ type rateLimitRegistry struct {
87119
// burst is the maximum burst size for newly created
88120
// limiters.
89121
burst int
122+
123+
// stop signals the cleanup goroutine to exit.
124+
stop chan struct{}
90125
}
91126

92127
// newRateLimitRegistry creates a registry that produces limiters
93-
// with the given sustained rate and burst size.
94-
func newRateLimitRegistry(rps float64, burst int) *rateLimitRegistry {
95-
return &rateLimitRegistry{
96-
limiters: make(map[string]*rate.Limiter),
97-
rps: rps,
98-
burst: burst,
128+
// with the given sustained rate and burst size. It starts a
129+
// background goroutine that evicts entries idle longer than
130+
// maxIdle at the given interval. Call [rateLimitRegistry.close]
131+
// to stop the goroutine.
132+
func newRateLimitRegistry(
133+
rps float64,
134+
burst int,
135+
cleanupInterval time.Duration,
136+
maxIdle time.Duration,
137+
) *rateLimitRegistry {
138+
registry := &rateLimitRegistry{
139+
entries: make(map[string]*rateLimitEntry),
140+
rps: rps,
141+
burst: burst,
142+
stop: make(chan struct{}),
99143
}
144+
145+
go registry.cleanup(cleanupInterval, maxIdle)
146+
147+
return registry
100148
}
101149

102150
// get returns the limiter for key, creating one if it does not
103-
// already exist.
151+
// already exist. It updates the entry's last-seen timestamp on
152+
// every access.
104153
func (registry *rateLimitRegistry) get(key string) *rate.Limiter {
105154
registry.mu.Lock()
106155
defer registry.mu.Unlock()
107156

108-
if limiter, ok := registry.limiters[key]; ok {
109-
return limiter
157+
if entry, ok := registry.entries[key]; ok {
158+
entry.lastSeen = time.Now()
159+
160+
return entry.limiter
110161
}
111162

112163
limiter := rate.NewLimiter(rate.Limit(registry.rps), registry.burst)
113-
registry.limiters[key] = limiter
164+
165+
registry.entries[key] = &rateLimitEntry{
166+
limiter: limiter,
167+
lastSeen: time.Now(),
168+
}
114169

115170
return limiter
116171
}
117172

118-
// RateLimit returns middleware that limits requests to 10 req/s
119-
// per IP with a burst of 20 using [DefaultRateLimitOptions].
173+
// size returns the number of entries in the registry.
174+
func (registry *rateLimitRegistry) size() int {
175+
registry.mu.Lock()
176+
defer registry.mu.Unlock()
177+
178+
return len(registry.entries)
179+
}
180+
181+
// cleanup periodically removes entries that have been idle
182+
// longer than maxIdle. It runs until [rateLimitRegistry.close]
183+
// is called.
184+
func (registry *rateLimitRegistry) cleanup(
185+
interval time.Duration,
186+
maxIdle time.Duration,
187+
) {
188+
ticker := time.NewTicker(interval)
189+
defer ticker.Stop()
190+
191+
for {
192+
select {
193+
case <-registry.stop:
194+
return
195+
case now := <-ticker.C:
196+
registry.mu.Lock()
197+
198+
for key, entry := range registry.entries {
199+
if now.Sub(entry.lastSeen) > maxIdle {
200+
delete(registry.entries, key)
201+
}
202+
}
203+
204+
registry.mu.Unlock()
205+
}
206+
}
207+
}
208+
209+
// close stops the background cleanup goroutine.
210+
func (registry *rateLimitRegistry) close() {
211+
close(registry.stop)
212+
}
213+
214+
// RateLimit returns middleware that limits requests to 15 req/s
215+
// per IP with a burst of 30 using [DefaultRateLimitOptions].
216+
// Idle entries are automatically evicted after 5 minutes.
120217
func RateLimit() framework.Middleware {
121218
return RateLimitWith(DefaultRateLimitOptions)
122219
}
123220

124221
// RateLimitWith returns middleware that limits requests using
125222
// the provided options. It uses a per-key token bucket algorithm
126-
// backed by [golang.org/x/time/rate].
223+
// backed by [golang.org/x/time/rate]. A background goroutine
224+
// periodically evicts entries that have been idle longer than
225+
// [RateLimitOptions.MaxIdleTime] to prevent unbounded memory
226+
// growth.
127227
func RateLimitWith(opts RateLimitOptions) framework.Middleware {
128228
opts = opts.withDefaults()
129-
registry := newRateLimitRegistry(opts.RequestsPerSecond, opts.Burst)
229+
230+
registry := newRateLimitRegistry(
231+
opts.RequestsPerSecond,
232+
opts.Burst,
233+
opts.CleanupInterval,
234+
opts.MaxIdleTime,
235+
)
130236

131237
return func(next framework.Handler) framework.Handler {
132238
return func(w http.ResponseWriter, r *http.Request) error {

0 commit comments

Comments
 (0)