Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

adds middleware for rate limiting #1724

Merged
merged 40 commits into from
Jan 15, 2021
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
180d26b
adds middleware for rate limiting
iambenkay Dec 17, 2020
dc04e2c
added comment for InMemoryStore ShouldAllow
iambenkay Dec 17, 2020
e0e9688
removed redundant mutex declaration
iambenkay Dec 17, 2020
ef2377c
fixed lint issues
iambenkay Dec 17, 2020
9b63f99
removed sleep from tests
iambenkay Dec 17, 2020
34d9097
improved coverage
iambenkay Dec 17, 2020
02efca7
refactor: renames Identifiers, includes default SourceFunc
iambenkay Dec 17, 2020
8d34f11
Added last seen stats for visitor
iambenkay Dec 17, 2020
674665e
uses http Constants for improved readdability
iambenkay Dec 17, 2020
018105b
used other handler apart from default handler to mark custom error ha…
iambenkay Dec 17, 2020
21fbfc8
split tests into separate blocks
iambenkay Dec 18, 2020
2682655
adds comments for exported members Extractor and ErrorHandler
iambenkay Dec 18, 2020
8255716
adds cleanup method for stale visitors to RateLimiterMemoryStore
iambenkay Dec 18, 2020
604c323
makes cleanup implementation inhouse
iambenkay Dec 18, 2020
b5165d4
Avoid race for cleanup due to non-atomic access to store.expiresIn
lammel Dec 18, 2020
27e7115
Use a dedicated producer for rate testing
lammel Dec 18, 2020
56bf7a6
tidy commit
iambenkay Dec 19, 2020
24433cc
refactors tests, implicitly tests lastSeen property on visitor
iambenkay Dec 19, 2020
76e3e89
switches to mock of time module for time based tests
iambenkay Dec 19, 2020
e7d1344
improved coverage
iambenkay Dec 19, 2020
049d21d
replaces Rob Pike referential options with more conventional struct c…
iambenkay Dec 19, 2020
4326ec1
blocks racy access to lastCleanup
iambenkay Dec 19, 2020
1733765
Add benchmark tests for rate limiter
lammel Dec 21, 2020
4b3f2c8
Add rate limiter with sharded memory store
lammel Dec 21, 2020
3fffc7b
Racy access to store.lastCleanup eliminated
iambenkay Dec 23, 2020
f323d36
Remove RateLimiterShradedMemoryStore for now
lammel Dec 25, 2020
59530a3
Make fields for RateLimiterStoreConfig public for external configuration
lammel Dec 25, 2020
65b59c9
Improve docs for RateLimiter usage
lammel Dec 25, 2020
e6371e2
Fix ErrorHandler vs. DenyHandler usage for rate limiter
lammel Dec 25, 2020
1203b79
Simplify NewRateLimiterMemoryStore
lammel Dec 25, 2020
7d4566e
improved coverage
iambenkay Jan 5, 2021
4e32a58
updated errorHandler and denyHandler to use echo.HTTPError
iambenkay Jan 6, 2021
7dc77bb
Improve wording for error and comments
lammel Jan 6, 2021
8c7eac7
Remove duplicate lastSeen marking for Allow
lammel Jan 6, 2021
bcc7fe2
Merge branch 'master' of github.com:iambenkay/echo into feature/rate-…
iambenkay Jan 6, 2021
5374337
Improve wording for comments
lammel Jan 6, 2021
5f731d6
Add disclaimer on perf characteristics of memory store
lammel Jan 6, 2021
e58f4fd
Merge branch 'feature/rate-limiter-middleware' of https://github.com/…
lammel Jan 6, 2021
d210158
changes Allow signature on rate limiter to return err too
iambenkay Jan 7, 2021
bbfb0ab
Merge branch 'feature/rate-limiter-middleware' of github.com:iambenka…
iambenkay Jan 7, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ vendor
.idea
*.iml
*.out
.vscode
iambenkay marked this conversation as resolved.
Show resolved Hide resolved
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ require (
golang.org/x/net v0.0.0-20200822124328-c89045814202
golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6 // indirect
golang.org/x/text v0.3.3 // indirect
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 h1:Hir2P/De0WpUhtrKGGjvSb2YxUgyZ7EFOSLIcSSpiwE=
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
Expand Down
219 changes: 219 additions & 0 deletions middleware/rate_limiter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
package middleware

import (
"net/http"
"sync"
"time"

"github.com/labstack/echo/v4"
"golang.org/x/time/rate"
)

type (
// RateLimiterStore is the interface to be implemented by custom stores.
RateLimiterStore interface {
// Stores for the rate limiter have to implement the Allow method
Allow(identifier string) bool
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allow may have internal errors due to hash limits or other issues.
In this case it may make sense to make the signature to be Allow(identifier string) bool, error to be able to handle this special cases as we will be able to pass the error also to the DenyHandler. Use case would be for example to allow access (not apply rate limit) for DB connection errors (or other internal errors).

Not sure if this is needed, but it was also suggested by @aldas

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay let me include this. it is a valid point.

}
)

type (
// RateLimiterConfig defines the configuration for the rate limiter
RateLimiterConfig struct {
Skipper Skipper
BeforeFunc BeforeFunc
// IdentifierExtractor uses echo.Context to extract the identifier for a visitor
IdentifierExtractor Extractor
// Store defines a store for the rate limiter
Store RateLimiterStore
ErrorHandler ErrorHandler
DenyHandler ErrorHandler
}
// ErrorHandler provides a handler for returning errors from the middleware
ErrorHandler func(context echo.Context) error
// Extractor is used to extract data from echo.Context
Extractor func(context echo.Context) (string, error)
)

// DefaultRateLimiterConfig defines default values for RateLimiterConfig
var DefaultRateLimiterConfig = RateLimiterConfig{
lammel marked this conversation as resolved.
Show resolved Hide resolved
Skipper: DefaultSkipper,
IdentifierExtractor: func(ctx echo.Context) (string, error) {
id := ctx.RealIP()
return id, nil
},
ErrorHandler: func(context echo.Context) error {
lammel marked this conversation as resolved.
Show resolved Hide resolved
return context.JSON(http.StatusTooManyRequests, nil)
},
DenyHandler: func(context echo.Context) error {
return context.JSON(http.StatusForbidden, nil)
lammel marked this conversation as resolved.
Show resolved Hide resolved
},
}

/*
RateLimiter returns a rate limiting middleware
iambenkay marked this conversation as resolved.
Show resolved Hide resolved

e := echo.New()

var inMemoryStore = NewRateLimiterMemoryStore(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remember to change the documentation to use the struct. Sorry for asking this change, I know is my fault for suggesting "Self referential functions" 😢

RateLimiterMemoryStoreConfig{rate: 1, burst: 3, expiresIn: 3 * time.Minute}
)

e.GET("/rate-limited", func(c echo.Context) error {
return c.String(http.StatusOK, "test")
}, RateLimiter(inMemoryStore))
*/
func RateLimiter(store RateLimiterStore) echo.MiddlewareFunc {
config := DefaultRateLimiterConfig
config.Store = store

return RateLimiterWithConfig(config)
}

/*
RateLimiterWithConfig returns a rate limiting middleware

e := echo.New()

var inMemoryStore = NewRateLimiterMemoryStore(
RateLimiterMemoryStoreConfig{rate: 1, burst: 3, expiresIn: 3 * time.Minute}
)

config := RateLimiterConfig{
Skipper: DefaultSkipper,
IdentifierExtractor: func(ctx echo.Context) (string, error) {
id := ctx.RealIP()
return id, nil
},
ErrorHandler: func(context echo.Context) error {
return context.JSON(http.StatusTooManyRequests, nil)
},
DenyHandler: func(context echo.Context) error {
return context.JSON(http.StatusForbidden, nil)
},
}

e.GET("/rate-limited", func(c echo.Context) error {
return c.String(http.StatusOK, "test")
}, RateLimiterWithConfig(config))
*/
func RateLimiterWithConfig(config RateLimiterConfig) echo.MiddlewareFunc {
if config.Skipper == nil {
config.Skipper = DefaultRateLimiterConfig.Skipper
}
if config.IdentifierExtractor == nil {
config.IdentifierExtractor = DefaultRateLimiterConfig.IdentifierExtractor
}
if config.ErrorHandler == nil {
config.ErrorHandler = DefaultRateLimiterConfig.ErrorHandler
}
if config.DenyHandler == nil {
config.DenyHandler = DefaultRateLimiterConfig.DenyHandler
}
if config.Store == nil {
panic("Store configuration must be provided")
}
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if config.Skipper(c) {
return next(c)
}
if config.BeforeFunc != nil {
config.BeforeFunc(c)
}

identifier, err := config.IdentifierExtractor(c)
if err != nil {
return config.DenyHandler(c)
}

if !config.Store.Allow(identifier) {
return config.ErrorHandler(c)
lammel marked this conversation as resolved.
Show resolved Hide resolved
}
return next(c)
}
}
}

type (
// RateLimiterMemoryStore is the built-in store implementation for RateLimiter
RateLimiterMemoryStore struct {
visitors map[string]*Visitor
mutex sync.Mutex
lammel marked this conversation as resolved.
Show resolved Hide resolved
rate rate.Limit
burst int
expiresIn time.Duration
lastCleanup time.Time
}
// Visitor signifies a unique user's limiter details
Visitor struct {
*rate.Limiter
lastSeen time.Time
}
)

// NewRateLimiterMemoryStore returns an instance of RateLimiterMemoryStore
func NewRateLimiterMemoryStore(config RateLimiterMemoryStoreConfig) (store *RateLimiterMemoryStore) {
store = &RateLimiterMemoryStore{}

store.rate = config.rate
store.burst = config.burst
if config.expiresIn == 0 {
store.expiresIn = DefaultRateLimiterMemoryStoreConfig.expiresIn
} else {
store.expiresIn = config.expiresIn
}
store.visitors = make(map[string]*Visitor)
store.lastCleanup = now()
return
}

// RateLimiterMemoryStoreConfig represents configuration for RateLimiterMemoryStore
type RateLimiterMemoryStoreConfig struct {
rate rate.Limit
lammel marked this conversation as resolved.
Show resolved Hide resolved
burst int
expiresIn time.Duration
}

// DefaultRateLimiterMemoryStoreConfig provides default configuration values for RateLimiterMemoryStore
var DefaultRateLimiterMemoryStoreConfig = RateLimiterMemoryStoreConfig{
lammel marked this conversation as resolved.
Show resolved Hide resolved
expiresIn: 3 * time.Minute,
}

// Allow implements RateLimiterStore.Allow
func (store *RateLimiterMemoryStore) Allow(identifier string) bool {
store.mutex.Lock()
limiter, exists := store.visitors[identifier]
if !exists {
limiter = new(Visitor)
limiter.Limiter = rate.NewLimiter(store.rate, store.burst)
limiter.lastSeen = now()
store.visitors[identifier] = limiter
}
limiter.lastSeen = now()
lammel marked this conversation as resolved.
Show resolved Hide resolved
if now().Sub(store.lastCleanup) > store.expiresIn {
store.cleanupStaleVisitors()
}
store.mutex.Unlock()
return limiter.AllowN(now(), 1)
}

/*
cleanupStaleVisitors helps manage the size of the visitors map by removing stale records
of users who haven't visited again after the configured expiry time has elapsed
*/
func (store *RateLimiterMemoryStore) cleanupStaleVisitors() {
for id, visitor := range store.visitors {
if now().Sub(visitor.lastSeen) > store.expiresIn {
delete(store.visitors, id)
}
}
store.lastCleanup = now()
}

/*
actual time method which is mocked in test file
*/
var now = func() time.Time {
return time.Now()
}
118 changes: 118 additions & 0 deletions middleware/rate_limiter_shard_store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package middleware

import (
"sync"
"time"

"golang.org/x/time/rate"
)

const (
numShards = 15
)

type (
// RateLimiterShardedMemoryStore is the built-in store implementation for RateLimiter
RateLimiterShardedMemoryStore struct {
shards []*VisitorShard
rate rate.Limit
burst int
expiresIn time.Duration
}
// VisitorShard is a lockable memory map
VisitorShard struct {
visitors map[string]*Visitor
mutex sync.Mutex
expiresIn time.Duration
expires time.Time
}
)

// NewRateLimiterShardedMemoryStore returns an instance of RateLimiterMemoryStore
func NewRateLimiterShardedMemoryStore(config RateLimiterMemoryStoreConfig) (store *RateLimiterShardedMemoryStore) {
store = &RateLimiterShardedMemoryStore{}

store.rate = config.rate
store.burst = config.burst
if config.expiresIn == 0 {
store.expiresIn = DefaultRateLimiterShardedMemoryStoreConfig.expiresIn
} else {
store.expiresIn = config.expiresIn
}
store.shards = make([]*VisitorShard, numShards)
for i := 0; i < numShards; i++ {
store.shards[i] = NewVisitorShard(store.expiresIn)
}

return store
}

// RateLimiterShardedMemoryStoreConfig represents configuration for RateLimiterShardedMemoryStore
type RateLimiterShardedMemoryStoreConfig struct {
rate rate.Limit
burst int
expiresIn time.Duration
}

// DefaultRateLimiterShardedMemoryStoreConfig provides default configuration values for RateLimiterShardedMemoryStore
var DefaultRateLimiterShardedMemoryStoreConfig = RateLimiterMemoryStoreConfig{
expiresIn: 3 * time.Minute,
}

// Allow implements RateLimiterStore.Allow
func (store *RateLimiterShardedMemoryStore) Allow(identifier string) bool {

shardIdx := byte(0)
for _, b := range []byte(identifier) {
shardIdx += b
}
shardIdx = shardIdx % numShards
// println(shardIdx)
shard := store.shards[shardIdx]

shard.mutex.Lock()
limiter, exists := shard.visitors[identifier]
if !exists {
// Not found in current shard, attempt other shard too
limiter, exists = shard.visitors[identifier]
if !exists || now().Sub(limiter.lastSeen) < store.expiresIn {
// Create a new limiter unless exists and not expired
limiter = new(Visitor)
limiter.Limiter = rate.NewLimiter(store.rate, store.burst)
limiter.lastSeen = now()
}
shard.visitors[identifier] = limiter
}
limiter.lastSeen = now()

if now().After(shard.expires) {
shard.expires = now().Add(shard.expiresIn)
go shard.Cleanup()
}

shard.mutex.Unlock()

return limiter.AllowN(now(), 1)
}

// NewVisitorShard returns a visitor shard
func NewVisitorShard(expiresIn time.Duration) *VisitorShard {
v := &VisitorShard{
visitors: make(map[string]*Visitor),
expiresIn: expiresIn,
expires: now().Add(expiresIn),
}
return v
}

// Cleanup a shards completely
func (shard *VisitorShard) Cleanup() {
shard.mutex.Lock()
for id, visitor := range shard.visitors {
if now().Sub(visitor.lastSeen) > shard.expiresIn {
delete(shard.visitors, id)
}
}
shard.expires = now().Add(shard.expiresIn)
shard.mutex.Unlock()
}
Loading