-
-
Notifications
You must be signed in to change notification settings - Fork 799
Expand file tree
/
Copy pathrate_limiter.go
More file actions
275 lines (249 loc) · 9.32 KB
/
Copy pathrate_limiter.go
File metadata and controls
275 lines (249 loc) · 9.32 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
// Copyright (c) 2015-present Jeevanandam M (jeeva@myjeeva.com), All rights reserved.
// resty source code and usage is governed by a MIT style
// license that can be found in the LICENSE file.
// SPDX-License-Identifier: MIT
package resty
import (
"context"
"errors"
"sync"
"time"
)
// ErrRateLimitExceeded is returned by [Client] execute method when the rate limiter
// rejects a request. This occurs when the context is cancelled or the deadline
// expires before a token becomes available, or immediately if the rate limiter
// implementation rejects the request for any other reason.
var ErrRateLimitExceeded = errors.New("resty: rate limit exceeded")
// RateLimiter is the interface that wraps the rate limiting behavior used by
// [Client]. Implement this interface to provide custom rate limiting strategies.
// The [Client] calls [RateLimiter.Allow] before every request; if it returns
// an error the request is aborted with that error.
//
// The context passed to [RateLimiter.Allow] is the request context, so
// cancellation or deadline expiry is respected automatically. Implementations
// must be safe for concurrent use.
type RateLimiter interface {
// Allow blocks until the rate limiter permits the next request or the
// context is done. It returns [ErrRateLimitExceeded] if the context expires
// or is cancelled before a token is available, and nil when the request may
// proceed. Implementations must be goroutine-safe.
Allow(ctx context.Context) error
}
// NewRateLimitTokenBucket creates a new token-bucket [RateLimiter] that permits at most
// requests per second with a burst capacity of burst tokens.
//
// The burst value controls how many requests can be issued instantly; after the
// burst is exhausted, tokens refill at the rate of request tokens per second.
//
// For example, to allow 100 requests per second with a burst of 10:
//
// rateLimiter := resty.NewRateLimitTokenBucket(100, 10)
// client := resty.New().SetRateLimiter(rateLimiter)
//
// A burst of 1 enforces strict rate limiting with no burstiness.
//
// If requestsPerSecond <= 0, NewRateLimitTokenBucket defaults to 5 requests per second.
// If burst <= 0, NewRateLimitTokenBucket defaults to a burst of 1.
func NewRateLimitTokenBucket(requestsPerSecond float64, burst int) *RateLimitTokenBucket {
if requestsPerSecond <= 0 {
// Default to 5 requests per second if invalid rate is provided.
requestsPerSecond = 5
}
if burst <= 0 {
// Default to a burst of 1 if invalid burst is provided.
burst = 1
}
l := &RateLimitTokenBucket{
rate: requestsPerSecond,
burst: burst,
tokens: float64(burst),
}
l.lastRefill = time.Now()
return l
}
var _ RateLimiter = (*RateLimitTokenBucket)(nil)
// RateLimitTokenBucket is a token-bucket based implementation of [RateLimiter].
// It implements the standard token-bucket algorithm: tokens refill at a
// constant rate and each request consumes one token. When no tokens are
// available, [RateLimitTokenBucket.Allow] blocks until either a token becomes
// available or the context expires.
//
// This implementation is safe for concurrent use from multiple goroutines.
// The token count is internally synchronized; access the rate and burst
// separately through [RateLimitTokenBucket.Rate] and [RateLimitTokenBucket.Burst].
//
// Create instances with [NewRateLimitTokenBucket]; do not use the zero value directly.
type RateLimitTokenBucket struct {
mu sync.Mutex
rate float64 // tokens per second
burst int // max token capacity
tokens float64 // current token count
lastRefill time.Time // last refill timestamp
}
// Rate method returns the token refill rate in requests per second.
func (l *RateLimitTokenBucket) Rate() float64 {
l.mu.Lock()
defer l.mu.Unlock()
return l.rate
}
// Burst method returns the maximum burst capacity (maximum token count).
func (l *RateLimitTokenBucket) Burst() int {
l.mu.Lock()
defer l.mu.Unlock()
return l.burst
}
// Allow blocks until the rate limiter grants a token or the context is done.
// It returns [ErrRateLimitExceeded] if the context is cancelled or times out
// before a token is available.
//
// Performance note: Timer allocations occur only when tokens are exhausted and
// waiting is necessary. When tokens are available (the common case), Allow
// returns immediately without allocating timers. Context deadline and
// cancellation checks are performed on every iteration,
// respecting cancellation immediately even during token waits.
func (l *RateLimitTokenBucket) Allow(ctx context.Context) error {
for {
// Check context first to avoid acquiring the lock unnecessarily.
select {
case <-ctx.Done():
return ErrRateLimitExceeded
default:
}
l.mu.Lock()
l.refill()
if l.tokens >= 1 {
l.tokens--
l.mu.Unlock()
return nil
}
// Calculate time needed for the missing fractional token amount.
missingTokens := 1 - l.tokens
wait := max(time.Duration((missingTokens/l.rate)*float64(time.Second)), time.Nanosecond)
l.mu.Unlock()
timer := time.NewTimer(wait)
select {
case <-ctx.Done():
timer.Stop()
return ErrRateLimitExceeded
case <-timer.C:
}
}
}
// refill adds tokens based on the elapsed time since the last refill.
// Must be called with l.mu held.
func (l *RateLimitTokenBucket) refill() {
now := time.Now()
elapsed := now.Sub(l.lastRefill).Seconds()
l.tokens = min(float64(l.burst), l.tokens+elapsed*l.rate)
l.lastRefill = now
}
// NewRateLimitSlidingWindow creates a new sliding-window [RateLimiter] that
// allows at most limit requests within any rolling window of windowSize duration.
//
// Unlike the token-bucket limiter which refills tokens at a constant rate, the
// sliding window continuously tracks when requests were made and permits a new
// request only when fewer than limit requests occurred in the past windowSize
// duration.
//
// For example, to allow 100 requests per 10 seconds:
//
// rateLimiter := resty.NewRateLimitSlidingWindow(100, 10*time.Second)
// client := resty.New().SetRateLimiter(rateLimiter)
//
// If limit <= 0, it defaults to 5. If windowSize <= 0, it defaults to 1 second.
func NewRateLimitSlidingWindow(limit int, windowSize time.Duration) *RateLimitSlidingWindow {
if limit <= 0 {
limit = 5
}
if windowSize <= 0 {
windowSize = time.Second
}
return &RateLimitSlidingWindow{
limit: limit,
windowSize: windowSize,
timestamps: make([]time.Time, 0, limit),
}
}
var _ RateLimiter = (*RateLimitSlidingWindow)(nil)
// RateLimitSlidingWindow is a sliding-window based implementation of [RateLimiter].
// It tracks request timestamps and allows a new request only when the number of
// requests within the past windowSize is below the configured limit.
//
// This implementation is safe for concurrent use from multiple goroutines.
// Memory usage is proportional to (limit * average_request_rate * windowSize);
// old timestamps are automatically evicted as they slide out of the window.
// Access rate and window size through [RateLimitSlidingWindow.Limit] and
// [RateLimitSlidingWindow.WindowSize].
//
// Compared to token-bucket: sliding window provides stricter enforcement of the
// request limit within discrete time windows, while token-bucket focuses on
// average rate with burst tolerance.
//
// Create instances with [NewRateLimitSlidingWindow]; do not use the zero value directly.
type RateLimitSlidingWindow struct {
mu sync.Mutex
limit int // max requests per window
windowSize time.Duration // duration of the sliding window
timestamps []time.Time // ordered slice of in-window request timestamps
}
// Limit returns the maximum number of requests allowed per window.
func (l *RateLimitSlidingWindow) Limit() int {
l.mu.Lock()
defer l.mu.Unlock()
return l.limit
}
// WindowSize returns the duration of the sliding window.
func (l *RateLimitSlidingWindow) WindowSize() time.Duration {
l.mu.Lock()
defer l.mu.Unlock()
return l.windowSize
}
// Allow blocks until the sliding window permits the next request or the context
// is done. It returns [ErrRateLimitExceeded] if the context is cancelled or
// times out before a slot becomes available.
//
// Performance note: When a slot is available (the common case), Allow returns
// immediately after evicting out-of-window timestamps. The eviction is O(n)
// where n is the number of out-of-window timestamps, but typically small due
// to sliding window semantics. Context cancellation is checked before and during
// any wait period, respecting cancellation immediately.
func (l *RateLimitSlidingWindow) Allow(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ErrRateLimitExceeded
default:
}
l.mu.Lock()
now := time.Now()
windowStart := now.Add(-l.windowSize)
// Evict timestamps that have slid out of the window.
i := 0
for i < len(l.timestamps) && l.timestamps[i].Before(windowStart) {
i++
}
if i > 0 {
// Use copy() to clear old elements and allow GC to reclaim memory.
copy(l.timestamps, l.timestamps[i:])
l.timestamps = l.timestamps[:len(l.timestamps)-i]
}
if len(l.timestamps) < l.limit {
l.timestamps = append(l.timestamps, now)
l.mu.Unlock()
return nil
}
// Wait until the oldest in-window timestamp slides out.
wait := l.timestamps[0].Add(l.windowSize).Sub(now)
if wait <= 0 {
wait = time.Nanosecond
}
l.mu.Unlock()
timer := time.NewTimer(wait)
select {
case <-ctx.Done():
timer.Stop()
return ErrRateLimitExceeded
case <-timer.C:
}
}
}