Skip to content

Commit 7c8592a

Browse files
iambenkaylammel
andauthored
adds middleware for rate limiting (#1724)
* adds middleware for rate limiting * added comment for InMemoryStore ShouldAllow * removed redundant mutex declaration * fixed lint issues * removed sleep from tests * improved coverage * refactor: renames Identifiers, includes default SourceFunc * Added last seen stats for visitor * uses http Constants for improved readdability adds default error handler * used other handler apart from default handler to mark custom error handler for rate limiting * split tests into separate blocks added an error pair to IdentifierExtractor Includes deny handler for explicitly denying requests * adds comments for exported members Extractor and ErrorHandler * makes cleanup implementation inhouse * Avoid race for cleanup due to non-atomic access to store.expiresIn * Use a dedicated producer for rate testing * tidy commit * refactors tests, implicitly tests lastSeen property on visitor switches NewRateLimiterMemoryStore constructor to Referential Functions style (Advised by @pafuent) * switches to mock of time module for time based tests tests are now fully deterministic * improved coverage * replaces Rob Pike referential options with more conventional struct configs makes cleanup asynchronous * blocks racy access to lastCleanup * Add benchmark tests for rate limiter * Add rate limiter with sharded memory store * Racy access to store.lastCleanup eliminated Merges in shiny sharded map implementation by @lammel * Remove RateLimiterShradedMemoryStore for now * Make fields for RateLimiterStoreConfig public for external configuration * Improve docs for RateLimiter usage * Fix ErrorHandler vs. DenyHandler usage for rate limiter * Simplify NewRateLimiterMemoryStore * improved coverage * updated errorHandler and denyHandler to use echo.HTTPError * Improve wording for error and comments * Remove duplicate lastSeen marking for Allow * Improve wording for comments * Add disclaimer on perf characteristics of memory store * changes Allow signature on rate limiter to return err too Co-authored-by: Roland Lammel <[email protected]>
1 parent 9b0e630 commit 7c8592a

File tree

5 files changed

+734
-0
lines changed

5 files changed

+734
-0
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ vendor
55
.idea
66
*.iml
77
*.out
8+
.vscode

go.mod

+1
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ require (
1212
golang.org/x/net v0.0.0-20200822124328-c89045814202
1313
golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6 // indirect
1414
golang.org/x/text v0.3.3 // indirect
15+
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324
1516
)

go.sum

+2
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
4646
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
4747
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
4848
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
49+
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 h1:Hir2P/De0WpUhtrKGGjvSb2YxUgyZ7EFOSLIcSSpiwE=
50+
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
4951
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
5052
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
5153
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

middleware/rate_limiter.go

+268
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
package middleware
2+
3+
import (
4+
"net/http"
5+
"sync"
6+
"time"
7+
8+
"github.com/labstack/echo/v4"
9+
"golang.org/x/time/rate"
10+
)
11+
12+
type (
13+
// RateLimiterStore is the interface to be implemented by custom stores.
14+
RateLimiterStore interface {
15+
// Stores for the rate limiter have to implement the Allow method
16+
Allow(identifier string) (bool, error)
17+
}
18+
)
19+
20+
type (
21+
// RateLimiterConfig defines the configuration for the rate limiter
22+
RateLimiterConfig struct {
23+
Skipper Skipper
24+
BeforeFunc BeforeFunc
25+
// IdentifierExtractor uses echo.Context to extract the identifier for a visitor
26+
IdentifierExtractor Extractor
27+
// Store defines a store for the rate limiter
28+
Store RateLimiterStore
29+
// ErrorHandler provides a handler to be called when IdentifierExtractor returns an error
30+
ErrorHandler func(context echo.Context, err error) error
31+
// DenyHandler provides a handler to be called when RateLimiter denies access
32+
DenyHandler func(context echo.Context, identifier string, err error) error
33+
}
34+
// Extractor is used to extract data from echo.Context
35+
Extractor func(context echo.Context) (string, error)
36+
)
37+
38+
// errors
39+
var (
40+
// ErrRateLimitExceeded denotes an error raised when rate limit is exceeded
41+
ErrRateLimitExceeded = echo.NewHTTPError(http.StatusTooManyRequests, "rate limit exceeded")
42+
// ErrExtractorError denotes an error raised when extractor function is unsuccessful
43+
ErrExtractorError = echo.NewHTTPError(http.StatusForbidden, "error while extracting identifier")
44+
)
45+
46+
// DefaultRateLimiterConfig defines default values for RateLimiterConfig
47+
var DefaultRateLimiterConfig = RateLimiterConfig{
48+
Skipper: DefaultSkipper,
49+
IdentifierExtractor: func(ctx echo.Context) (string, error) {
50+
id := ctx.RealIP()
51+
return id, nil
52+
},
53+
ErrorHandler: func(context echo.Context, err error) error {
54+
return &echo.HTTPError{
55+
Code: ErrExtractorError.Code,
56+
Message: ErrExtractorError.Message,
57+
Internal: err,
58+
}
59+
},
60+
DenyHandler: func(context echo.Context, identifier string, err error) error {
61+
return &echo.HTTPError{
62+
Code: ErrRateLimitExceeded.Code,
63+
Message: ErrRateLimitExceeded.Message,
64+
Internal: err,
65+
}
66+
},
67+
}
68+
69+
/*
70+
RateLimiter returns a rate limiting middleware
71+
72+
e := echo.New()
73+
74+
limiterStore := middleware.NewRateLimiterMemoryStore(20)
75+
76+
e.GET("/rate-limited", func(c echo.Context) error {
77+
return c.String(http.StatusOK, "test")
78+
}, RateLimiter(limiterStore))
79+
*/
80+
func RateLimiter(store RateLimiterStore) echo.MiddlewareFunc {
81+
config := DefaultRateLimiterConfig
82+
config.Store = store
83+
84+
return RateLimiterWithConfig(config)
85+
}
86+
87+
/*
88+
RateLimiterWithConfig returns a rate limiting middleware
89+
90+
e := echo.New()
91+
92+
config := middleware.RateLimiterConfig{
93+
Skipper: DefaultSkipper,
94+
Store: middleware.NewRateLimiterMemoryStore(
95+
middleware.RateLimiterMemoryStoreConfig{Rate: 10, Burst: 30, ExpiresIn: 3 * time.Minute}
96+
)
97+
IdentifierExtractor: func(ctx echo.Context) (string, error) {
98+
id := ctx.RealIP()
99+
return id, nil
100+
},
101+
ErrorHandler: func(context echo.Context, err error) error {
102+
return context.JSON(http.StatusTooManyRequests, nil)
103+
},
104+
DenyHandler: func(context echo.Context, identifier string) error {
105+
return context.JSON(http.StatusForbidden, nil)
106+
},
107+
}
108+
109+
e.GET("/rate-limited", func(c echo.Context) error {
110+
return c.String(http.StatusOK, "test")
111+
}, middleware.RateLimiterWithConfig(config))
112+
*/
113+
func RateLimiterWithConfig(config RateLimiterConfig) echo.MiddlewareFunc {
114+
if config.Skipper == nil {
115+
config.Skipper = DefaultRateLimiterConfig.Skipper
116+
}
117+
if config.IdentifierExtractor == nil {
118+
config.IdentifierExtractor = DefaultRateLimiterConfig.IdentifierExtractor
119+
}
120+
if config.ErrorHandler == nil {
121+
config.ErrorHandler = DefaultRateLimiterConfig.ErrorHandler
122+
}
123+
if config.DenyHandler == nil {
124+
config.DenyHandler = DefaultRateLimiterConfig.DenyHandler
125+
}
126+
if config.Store == nil {
127+
panic("Store configuration must be provided")
128+
}
129+
return func(next echo.HandlerFunc) echo.HandlerFunc {
130+
return func(c echo.Context) error {
131+
if config.Skipper(c) {
132+
return next(c)
133+
}
134+
if config.BeforeFunc != nil {
135+
config.BeforeFunc(c)
136+
}
137+
138+
identifier, err := config.IdentifierExtractor(c)
139+
if err != nil {
140+
c.Error(config.ErrorHandler(c, err))
141+
return nil
142+
}
143+
144+
if allow, err := config.Store.Allow(identifier); !allow {
145+
c.Error(config.DenyHandler(c, identifier, err))
146+
return nil
147+
}
148+
return next(c)
149+
}
150+
}
151+
}
152+
153+
type (
154+
// RateLimiterMemoryStore is the built-in store implementation for RateLimiter
155+
RateLimiterMemoryStore struct {
156+
visitors map[string]*Visitor
157+
mutex sync.Mutex
158+
rate rate.Limit
159+
burst int
160+
expiresIn time.Duration
161+
lastCleanup time.Time
162+
}
163+
// Visitor signifies a unique user's limiter details
164+
Visitor struct {
165+
*rate.Limiter
166+
lastSeen time.Time
167+
}
168+
)
169+
170+
/*
171+
NewRateLimiterMemoryStore returns an instance of RateLimiterMemoryStore with
172+
the provided rate (as req/s). Burst and ExpiresIn will be set to default values.
173+
174+
Example (with 20 requests/sec):
175+
176+
limiterStore := middleware.NewRateLimiterMemoryStore(20)
177+
178+
*/
179+
func NewRateLimiterMemoryStore(rate rate.Limit) (store *RateLimiterMemoryStore) {
180+
return NewRateLimiterMemoryStoreWithConfig(RateLimiterMemoryStoreConfig{
181+
Rate: rate,
182+
})
183+
}
184+
185+
/*
186+
NewRateLimiterMemoryStoreWithConfig returns an instance of RateLimiterMemoryStore
187+
with the provided configuration. Rate must be provided. Burst will be set to the value of
188+
the configured rate if not provided or set to 0.
189+
190+
The build-in memory store is usually capable for modest loads. For higher loads other
191+
store implementations should be considered.
192+
193+
Characteristics:
194+
* Concurrency above 100 parallel requests may causes measurable lock contention
195+
* A high number of different IP addresses (above 16000) may be impacted by the internally used Go map
196+
* A high number of requests from a single IP address may cause lock contention
197+
198+
Example:
199+
200+
limiterStore := middleware.NewRateLimiterMemoryStoreWithConfig(
201+
middleware.RateLimiterMemoryStoreConfig{Rate: 50, Burst: 200, ExpiresIn: 5 * time.Minutes},
202+
)
203+
*/
204+
func NewRateLimiterMemoryStoreWithConfig(config RateLimiterMemoryStoreConfig) (store *RateLimiterMemoryStore) {
205+
store = &RateLimiterMemoryStore{}
206+
207+
store.rate = config.Rate
208+
store.burst = config.Burst
209+
store.expiresIn = config.ExpiresIn
210+
if config.ExpiresIn == 0 {
211+
store.expiresIn = DefaultRateLimiterMemoryStoreConfig.ExpiresIn
212+
}
213+
if config.Burst == 0 {
214+
store.burst = int(config.Rate)
215+
}
216+
store.visitors = make(map[string]*Visitor)
217+
store.lastCleanup = now()
218+
return
219+
}
220+
221+
// RateLimiterMemoryStoreConfig represents configuration for RateLimiterMemoryStore
222+
type RateLimiterMemoryStoreConfig struct {
223+
Rate rate.Limit // Rate of requests allowed to pass as req/s
224+
Burst int // Burst additionally allows a number of requests to pass when rate limit is reached
225+
ExpiresIn time.Duration // ExpiresIn is the duration after that a rate limiter is cleaned up
226+
}
227+
228+
// DefaultRateLimiterMemoryStoreConfig provides default configuration values for RateLimiterMemoryStore
229+
var DefaultRateLimiterMemoryStoreConfig = RateLimiterMemoryStoreConfig{
230+
ExpiresIn: 3 * time.Minute,
231+
}
232+
233+
// Allow implements RateLimiterStore.Allow
234+
func (store *RateLimiterMemoryStore) Allow(identifier string) (bool, error) {
235+
store.mutex.Lock()
236+
limiter, exists := store.visitors[identifier]
237+
if !exists {
238+
limiter = new(Visitor)
239+
limiter.Limiter = rate.NewLimiter(store.rate, store.burst)
240+
store.visitors[identifier] = limiter
241+
}
242+
limiter.lastSeen = now()
243+
if now().Sub(store.lastCleanup) > store.expiresIn {
244+
store.cleanupStaleVisitors()
245+
}
246+
store.mutex.Unlock()
247+
return limiter.AllowN(now(), 1), nil
248+
}
249+
250+
/*
251+
cleanupStaleVisitors helps manage the size of the visitors map by removing stale records
252+
of users who haven't visited again after the configured expiry time has elapsed
253+
*/
254+
func (store *RateLimiterMemoryStore) cleanupStaleVisitors() {
255+
for id, visitor := range store.visitors {
256+
if now().Sub(visitor.lastSeen) > store.expiresIn {
257+
delete(store.visitors, id)
258+
}
259+
}
260+
store.lastCleanup = now()
261+
}
262+
263+
/*
264+
actual time method which is mocked in test file
265+
*/
266+
var now = func() time.Time {
267+
return time.Now()
268+
}

0 commit comments

Comments
 (0)