-
-
Notifications
You must be signed in to change notification settings - Fork 2.3k
adds middleware for rate limiting #1724
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
Merged
pafuent
merged 40 commits into
labstack:master
from
iambenkay:feature/rate-limiter-middleware
Jan 15, 2021
Merged
Changes from all commits
Commits
Show all changes
40 commits
Select commit
Hold shift + click to select a range
180d26b
adds middleware for rate limiting
iambenkay dc04e2c
added comment for InMemoryStore ShouldAllow
iambenkay e0e9688
removed redundant mutex declaration
iambenkay ef2377c
fixed lint issues
iambenkay 9b63f99
removed sleep from tests
iambenkay 34d9097
improved coverage
iambenkay 02efca7
refactor: renames Identifiers, includes default SourceFunc
iambenkay 8d34f11
Added last seen stats for visitor
iambenkay 674665e
uses http Constants for improved readdability
iambenkay 018105b
used other handler apart from default handler to mark custom error ha…
iambenkay 21fbfc8
split tests into separate blocks
iambenkay 2682655
adds comments for exported members Extractor and ErrorHandler
iambenkay 8255716
adds cleanup method for stale visitors to RateLimiterMemoryStore
iambenkay 604c323
makes cleanup implementation inhouse
iambenkay b5165d4
Avoid race for cleanup due to non-atomic access to store.expiresIn
lammel 27e7115
Use a dedicated producer for rate testing
lammel 56bf7a6
tidy commit
iambenkay 24433cc
refactors tests, implicitly tests lastSeen property on visitor
iambenkay 76e3e89
switches to mock of time module for time based tests
iambenkay e7d1344
improved coverage
iambenkay 049d21d
replaces Rob Pike referential options with more conventional struct c…
iambenkay 4326ec1
blocks racy access to lastCleanup
iambenkay 1733765
Add benchmark tests for rate limiter
lammel 4b3f2c8
Add rate limiter with sharded memory store
lammel 3fffc7b
Racy access to store.lastCleanup eliminated
iambenkay f323d36
Remove RateLimiterShradedMemoryStore for now
lammel 59530a3
Make fields for RateLimiterStoreConfig public for external configuration
lammel 65b59c9
Improve docs for RateLimiter usage
lammel e6371e2
Fix ErrorHandler vs. DenyHandler usage for rate limiter
lammel 1203b79
Simplify NewRateLimiterMemoryStore
lammel 7d4566e
improved coverage
iambenkay 4e32a58
updated errorHandler and denyHandler to use echo.HTTPError
iambenkay 7dc77bb
Improve wording for error and comments
lammel 8c7eac7
Remove duplicate lastSeen marking for Allow
lammel bcc7fe2
Merge branch 'master' of github.com:iambenkay/echo into feature/rate-…
iambenkay 5374337
Improve wording for comments
lammel 5f731d6
Add disclaimer on perf characteristics of memory store
lammel e58f4fd
Merge branch 'feature/rate-limiter-middleware' of https://github.com/…
lammel d210158
changes Allow signature on rate limiter to return err too
iambenkay bbfb0ab
Merge branch 'feature/rate-limiter-middleware' of github.com:iambenka…
iambenkay File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,3 +5,4 @@ vendor | |
.idea | ||
*.iml | ||
*.out | ||
.vscode | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,268 @@ | ||
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, error) | ||
} | ||
) | ||
|
||
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 provides a handler to be called when IdentifierExtractor returns an error | ||
ErrorHandler func(context echo.Context, err error) error | ||
// DenyHandler provides a handler to be called when RateLimiter denies access | ||
DenyHandler func(context echo.Context, identifier string, err error) error | ||
} | ||
// Extractor is used to extract data from echo.Context | ||
Extractor func(context echo.Context) (string, error) | ||
) | ||
|
||
// errors | ||
var ( | ||
// ErrRateLimitExceeded denotes an error raised when rate limit is exceeded | ||
ErrRateLimitExceeded = echo.NewHTTPError(http.StatusTooManyRequests, "rate limit exceeded") | ||
// ErrExtractorError denotes an error raised when extractor function is unsuccessful | ||
ErrExtractorError = echo.NewHTTPError(http.StatusForbidden, "error while extracting identifier") | ||
) | ||
|
||
// 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, err error) error { | ||
return &echo.HTTPError{ | ||
Code: ErrExtractorError.Code, | ||
Message: ErrExtractorError.Message, | ||
Internal: err, | ||
} | ||
}, | ||
DenyHandler: func(context echo.Context, identifier string, err error) error { | ||
return &echo.HTTPError{ | ||
Code: ErrRateLimitExceeded.Code, | ||
Message: ErrRateLimitExceeded.Message, | ||
Internal: err, | ||
} | ||
}, | ||
} | ||
|
||
/* | ||
RateLimiter returns a rate limiting middleware | ||
iambenkay marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
e := echo.New() | ||
|
||
limiterStore := middleware.NewRateLimiterMemoryStore(20) | ||
|
||
e.GET("/rate-limited", func(c echo.Context) error { | ||
return c.String(http.StatusOK, "test") | ||
}, RateLimiter(limiterStore)) | ||
*/ | ||
func RateLimiter(store RateLimiterStore) echo.MiddlewareFunc { | ||
config := DefaultRateLimiterConfig | ||
config.Store = store | ||
|
||
return RateLimiterWithConfig(config) | ||
} | ||
|
||
/* | ||
RateLimiterWithConfig returns a rate limiting middleware | ||
|
||
e := echo.New() | ||
|
||
config := middleware.RateLimiterConfig{ | ||
Skipper: DefaultSkipper, | ||
Store: middleware.NewRateLimiterMemoryStore( | ||
middleware.RateLimiterMemoryStoreConfig{Rate: 10, Burst: 30, ExpiresIn: 3 * time.Minute} | ||
) | ||
IdentifierExtractor: func(ctx echo.Context) (string, error) { | ||
id := ctx.RealIP() | ||
return id, nil | ||
}, | ||
ErrorHandler: func(context echo.Context, err error) error { | ||
return context.JSON(http.StatusTooManyRequests, nil) | ||
}, | ||
DenyHandler: func(context echo.Context, identifier string) error { | ||
return context.JSON(http.StatusForbidden, nil) | ||
}, | ||
} | ||
|
||
e.GET("/rate-limited", func(c echo.Context) error { | ||
return c.String(http.StatusOK, "test") | ||
}, middleware.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 { | ||
c.Error(config.ErrorHandler(c, err)) | ||
return nil | ||
} | ||
|
||
if allow, err := config.Store.Allow(identifier); !allow { | ||
c.Error(config.DenyHandler(c, identifier, err)) | ||
return nil | ||
} | ||
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 with | ||
the provided rate (as req/s). Burst and ExpiresIn will be set to default values. | ||
|
||
Example (with 20 requests/sec): | ||
|
||
limiterStore := middleware.NewRateLimiterMemoryStore(20) | ||
|
||
*/ | ||
func NewRateLimiterMemoryStore(rate rate.Limit) (store *RateLimiterMemoryStore) { | ||
return NewRateLimiterMemoryStoreWithConfig(RateLimiterMemoryStoreConfig{ | ||
Rate: rate, | ||
}) | ||
} | ||
|
||
/* | ||
NewRateLimiterMemoryStoreWithConfig returns an instance of RateLimiterMemoryStore | ||
with the provided configuration. Rate must be provided. Burst will be set to the value of | ||
the configured rate if not provided or set to 0. | ||
|
||
The build-in memory store is usually capable for modest loads. For higher loads other | ||
store implementations should be considered. | ||
|
||
Characteristics: | ||
* Concurrency above 100 parallel requests may causes measurable lock contention | ||
* A high number of different IP addresses (above 16000) may be impacted by the internally used Go map | ||
* A high number of requests from a single IP address may cause lock contention | ||
|
||
Example: | ||
|
||
limiterStore := middleware.NewRateLimiterMemoryStoreWithConfig( | ||
middleware.RateLimiterMemoryStoreConfig{Rate: 50, Burst: 200, ExpiresIn: 5 * time.Minutes}, | ||
) | ||
*/ | ||
func NewRateLimiterMemoryStoreWithConfig(config RateLimiterMemoryStoreConfig) (store *RateLimiterMemoryStore) { | ||
store = &RateLimiterMemoryStore{} | ||
|
||
store.rate = config.Rate | ||
store.burst = config.Burst | ||
store.expiresIn = config.ExpiresIn | ||
if config.ExpiresIn == 0 { | ||
store.expiresIn = DefaultRateLimiterMemoryStoreConfig.ExpiresIn | ||
} | ||
if config.Burst == 0 { | ||
store.burst = int(config.Rate) | ||
} | ||
store.visitors = make(map[string]*Visitor) | ||
store.lastCleanup = now() | ||
return | ||
} | ||
|
||
// RateLimiterMemoryStoreConfig represents configuration for RateLimiterMemoryStore | ||
type RateLimiterMemoryStoreConfig struct { | ||
Rate rate.Limit // Rate of requests allowed to pass as req/s | ||
Burst int // Burst additionally allows a number of requests to pass when rate limit is reached | ||
ExpiresIn time.Duration // ExpiresIn is the duration after that a rate limiter is cleaned up | ||
} | ||
|
||
// 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, error) { | ||
store.mutex.Lock() | ||
limiter, exists := store.visitors[identifier] | ||
if !exists { | ||
limiter = new(Visitor) | ||
limiter.Limiter = rate.NewLimiter(store.rate, store.burst) | ||
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), nil | ||
} | ||
|
||
/* | ||
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() | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.