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 6 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 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
97 changes: 97 additions & 0 deletions middleware/rate_limiter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package middleware

import (
"github.com/labstack/echo/v4"
"golang.org/x/time/rate"
"net/http"
"sync"
)

// TokenStore is the interface to be implemented by custom stores.
type TokenStore interface {
// Stores for the rate limiter have to implement the ShouldAllow method
ShouldAllow(identifier string) bool
lammel marked this conversation as resolved.
Show resolved Hide resolved
}

type (
// RateLimiterConfig defines the configuration for the rate limiter
RateLimiterConfig struct {
Skipper Skipper
BeforeFunc BeforeFunc
// SourceFunc uses echo.Context to extract the identifier for a visitor
SourceFunc func(context echo.Context) string
// Store defines a store for the rate limiter
Store TokenStore
}
)

// DefaultRateLimiterConfig defines default values for RateLimiterConfig
var DefaultRateLimiterConfig = RateLimiterConfig{
lammel marked this conversation as resolved.
Show resolved Hide resolved
Skipper: DefaultSkipper,
}

// RateLimiter returns a rate limiting middleware
lammel marked this conversation as resolved.
Show resolved Hide resolved
func RateLimiter(source func(context echo.Context) string, store TokenStore) echo.MiddlewareFunc {
config := DefaultRateLimiterConfig
config.SourceFunc = source
config.Store = store

return RateLimiterWithConfig(config)
}

// RateLimiterWithConfig returns a rate limiting middleware
func RateLimiterWithConfig(config RateLimiterConfig) echo.MiddlewareFunc {
if config.Skipper == nil {
config.Skipper = DefaultRateLimiterConfig.Skipper
}
if config.SourceFunc == nil {
panic("Source function must be provided")
}
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 := config.SourceFunc(c)

allowed := config.Store.ShouldAllow(identifier)
if !allowed {
return c.JSON(http.StatusTooManyRequests, nil)
lammel marked this conversation as resolved.
Show resolved Hide resolved
}
return next(c)
}
}
}

// InMemoryStore is the built-in store implementation for RateLimiter
type InMemoryStore struct {
lammel marked this conversation as resolved.
Show resolved Hide resolved
visitors map[string]*rate.Limiter
mutex sync.Mutex
rate rate.Limit
burst int
}

// ShouldAllow implements TokenStore.ShouldAllow
func (store *InMemoryStore) ShouldAllow(identifier string) bool {
store.mutex.Lock()
defer store.mutex.Unlock()
lammel marked this conversation as resolved.
Show resolved Hide resolved

if store.visitors == nil {
store.visitors = make(map[string]*rate.Limiter)
}

limiter, exists := store.visitors[identifier]
if !exists {
limiter = rate.NewLimiter(store.rate, store.burst)
store.visitors[identifier] = limiter
}

return limiter.Allow()
}
192 changes: 192 additions & 0 deletions middleware/rate_limiter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package middleware

import (
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"net/http"
"net/http/httptest"
"testing"
)

func TestRateLimiter(t *testing.T) {
var inMemoryStore = new(InMemoryStore)
inMemoryStore.rate = 1
inMemoryStore.burst = 3

e := echo.New()

handler := func(c echo.Context) error {
return c.String(http.StatusOK, "test")
}

assert.Panics(t, func() {
RateLimiter(nil, nil)
})

assert.Panics(t, func() {
RateLimiter(func(ctx echo.Context) string {
return "127.0.0.1"
}, nil)
})

assert.NotPanics(t, func() {
RateLimiter(func(ctx echo.Context) string {
return "127.0.0.1"
}, inMemoryStore)
})

{
iambenkay marked this conversation as resolved.
Show resolved Hide resolved
var skipped bool
var inMemoryStore = new(InMemoryStore)
inMemoryStore.rate = 1
inMemoryStore.burst = 3

req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Add(echo.HeaderXRealIP, "127.0.0.1")

rec := httptest.NewRecorder()

c := e.NewContext(req, rec)

mw := RateLimiterWithConfig(RateLimiterConfig{
Skipper: func(c echo.Context) bool {
skipped = true
return true
},
Store: inMemoryStore,
SourceFunc: func(ctx echo.Context) string {
return "127.0.0.1"
},
})

_ = mw(handler)(c)

assert.Equal(t, true, skipped)
iambenkay marked this conversation as resolved.
Show resolved Hide resolved
}

{
var beforeRan bool
var inMemoryStore = new(InMemoryStore)
inMemoryStore.rate = 1
inMemoryStore.burst = 3

req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Add(echo.HeaderXRealIP, "127.0.0.1")

rec := httptest.NewRecorder()

c := e.NewContext(req, rec)

mw := RateLimiterWithConfig(RateLimiterConfig{
BeforeFunc: func(c echo.Context) {
beforeRan = true
},
Store: inMemoryStore,
SourceFunc: func(ctx echo.Context) string {
return "127.0.0.1"
},
})

_ = mw(handler)(c)

assert.Equal(t, true, beforeRan)
}

testCases := []struct {
id string
code int
}{
{"127.0.0.1", 200},
{"127.0.0.1", 200},
{"127.0.0.1", 200},
{"127.0.0.1", 429},
{"127.0.0.1", 429},
{"127.0.0.1", 429},
{"127.0.0.1", 429},
lammel marked this conversation as resolved.
Show resolved Hide resolved
}

for _, tc := range testCases {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Add(echo.HeaderXRealIP, tc.id)

rec := httptest.NewRecorder()

c := e.NewContext(req, rec)
mw := RateLimiter(func(c echo.Context) string {
return c.Request().Header.Get(echo.HeaderXRealIP)
}, inMemoryStore)

_ = mw(handler)(c)

assert.Equal(t, tc.code, rec.Code)
}
}

func TestRateLimiterWithConfig(t *testing.T) {
var inMemoryStore = new(InMemoryStore)
inMemoryStore.rate = 1
inMemoryStore.burst = 3

e := echo.New()

handler := func(c echo.Context) error {
return c.String(http.StatusOK, "test")
}

testCases := []struct {
id string
code int
}{
{"127.0.0.1", 200},
{"127.0.0.1", 200},
{"127.0.0.1", 200},
{"127.0.0.1", 429},
{"127.0.0.1", 429},
{"127.0.0.1", 429},
{"127.0.0.1", 429},
}

for _, tc := range testCases {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Add(echo.HeaderXRealIP, tc.id)

rec := httptest.NewRecorder()

c := e.NewContext(req, rec)
mw := RateLimiterWithConfig(RateLimiterConfig{
SourceFunc: func(c echo.Context) string {
return c.Request().Header.Get(echo.HeaderXRealIP)
},
Store: inMemoryStore,
})

_ = mw(handler)(c)

assert.Equal(t, tc.code, rec.Code)
}
}

func TestInMemoryStore_ShouldAllow(t *testing.T) {
var inMemoryStore = new(InMemoryStore)
inMemoryStore.rate = 1
inMemoryStore.burst = 3

testCases := []struct {
id string
allowed bool
}{
{"127.0.0.1", true},
{"127.0.0.1", true},
{"127.0.0.1", true},
{"127.0.0.1", false},
{"127.0.0.1", false},
{"127.0.0.1", false},
{"127.0.0.1", false},
}

for _, tc := range testCases {
allowed := inMemoryStore.ShouldAllow(tc.id)

assert.Equal(t, tc.allowed, allowed)
}
}