Merge pull request #3679 from ethereum-optimism/10-09-proxyd_Custom_rate_limiter_implementation
proxyd: Custom rate limiter implementation
This commit is contained in:
commit
7fd63c6c6c
@ -41,6 +41,7 @@ type MetricsConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RateLimitConfig struct {
|
type RateLimitConfig struct {
|
||||||
|
UseRedis bool `toml:"use_redis"`
|
||||||
RatePerSecond int `toml:"rate_per_second"`
|
RatePerSecond int `toml:"rate_per_second"`
|
||||||
ExemptOrigins []string `toml:"exempt_origins"`
|
ExemptOrigins []string `toml:"exempt_origins"`
|
||||||
ExemptUserAgents []string `toml:"exempt_user_agents"`
|
ExemptUserAgents []string `toml:"exempt_user_agents"`
|
||||||
|
121
proxyd/proxyd/frontend_rate_limiter.go
Normal file
121
proxyd/proxyd/frontend_rate_limiter.go
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
package proxyd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-redis/redis/v8"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FrontendRateLimiter interface {
|
||||||
|
// Take consumes a key, and a maximum number of requests
|
||||||
|
// per time interval. It returns a boolean denoting if
|
||||||
|
// the limit could be taken, or an error if a failure
|
||||||
|
// occurred in the backing rate limit implementation.
|
||||||
|
//
|
||||||
|
// No error will be returned if the limit could not be taken
|
||||||
|
// as a result of the requestor being over the limit.
|
||||||
|
Take(ctx context.Context, key string, max int) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// limitedKeys is a wrapper around a map that stores a truncated
|
||||||
|
// timestamp and a mutex. The map is used to keep track of rate
|
||||||
|
// limit keys, and their used limits.
|
||||||
|
type limitedKeys struct {
|
||||||
|
truncTS int64
|
||||||
|
keys map[string]int
|
||||||
|
mtx sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLimitedKeys(t int64) *limitedKeys {
|
||||||
|
return &limitedKeys{
|
||||||
|
truncTS: t,
|
||||||
|
keys: make(map[string]int),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *limitedKeys) Take(key string, max int) bool {
|
||||||
|
l.mtx.Lock()
|
||||||
|
defer l.mtx.Unlock()
|
||||||
|
val, ok := l.keys[key]
|
||||||
|
if !ok {
|
||||||
|
l.keys[key] = 0
|
||||||
|
val = 0
|
||||||
|
}
|
||||||
|
l.keys[key] = val + 1
|
||||||
|
return val < max
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemoryFrontendRateLimiter is a rate limiter that stores
|
||||||
|
// all rate limiting information in local memory. It works
|
||||||
|
// by storing a limitedKeys struct that references the
|
||||||
|
// truncated timestamp at which the struct was created. If
|
||||||
|
// the current truncated timestamp doesn't match what's
|
||||||
|
// referenced, the limit is reset. Otherwise, values in
|
||||||
|
// a map are incremented to represent the limit.
|
||||||
|
type MemoryFrontendRateLimiter struct {
|
||||||
|
currGeneration *limitedKeys
|
||||||
|
dur time.Duration
|
||||||
|
mtx sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMemoryFrontendRateLimit(dur time.Duration) FrontendRateLimiter {
|
||||||
|
return &MemoryFrontendRateLimiter{
|
||||||
|
dur: dur,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryFrontendRateLimiter) Take(ctx context.Context, key string, max int) (bool, error) {
|
||||||
|
m.mtx.Lock()
|
||||||
|
// Create truncated timestamp
|
||||||
|
truncTS := truncateNow(m.dur)
|
||||||
|
|
||||||
|
// If there is no current rate limit map or the rate limit map reference
|
||||||
|
// a different timestamp, reset limits.
|
||||||
|
if m.currGeneration == nil || m.currGeneration.truncTS != truncTS {
|
||||||
|
m.currGeneration = newLimitedKeys(truncTS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull out the limiter so we can unlock before incrementing the limit.
|
||||||
|
limiter := m.currGeneration
|
||||||
|
|
||||||
|
m.mtx.Unlock()
|
||||||
|
|
||||||
|
return limiter.Take(key, max), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisFrontendRateLimiter is a rate limiter that stores data in Redis.
|
||||||
|
// It uses the basic rate limiter pattern described on the Redis best
|
||||||
|
// practices website: https://redis.com/redis-best-practices/basic-rate-limiting/.
|
||||||
|
type RedisFrontendRateLimiter struct {
|
||||||
|
r *redis.Client
|
||||||
|
dur time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRedisFrontendRateLimiter(r *redis.Client, dur time.Duration) FrontendRateLimiter {
|
||||||
|
return &RedisFrontendRateLimiter{r: r, dur: dur}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RedisFrontendRateLimiter) Take(ctx context.Context, key string, max int) (bool, error) {
|
||||||
|
var incr *redis.IntCmd
|
||||||
|
truncTS := truncateNow(r.dur)
|
||||||
|
fullKey := fmt.Sprintf("%s:%d", key, truncTS)
|
||||||
|
_, err := r.r.Pipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||||
|
incr = pipe.Incr(ctx, fullKey)
|
||||||
|
pipe.Expire(ctx, fullKey, r.dur-time.Second)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return incr.Val()-1 < int64(max), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateNow truncates the current timestamp
|
||||||
|
// to the specified duration.
|
||||||
|
func truncateNow(dur time.Duration) int64 {
|
||||||
|
return time.Now().Truncate(dur).Unix()
|
||||||
|
}
|
53
proxyd/proxyd/frontend_rate_limiter_test.go
Normal file
53
proxyd/proxyd/frontend_rate_limiter_test.go
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
package proxyd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/alicebob/miniredis"
|
||||||
|
"github.com/go-redis/redis/v8"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFrontendRateLimiter(t *testing.T) {
|
||||||
|
redisServer, err := miniredis.Run()
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer redisServer.Close()
|
||||||
|
|
||||||
|
redisClient := redis.NewClient(&redis.Options{
|
||||||
|
Addr: fmt.Sprintf("127.0.0.1:%s", redisServer.Port()),
|
||||||
|
})
|
||||||
|
|
||||||
|
lims := []struct {
|
||||||
|
name string
|
||||||
|
frl FrontendRateLimiter
|
||||||
|
}{
|
||||||
|
{"memory", NewMemoryFrontendRateLimit(2 * time.Second)},
|
||||||
|
{"redis", NewRedisFrontendRateLimiter(redisClient, 2*time.Second)},
|
||||||
|
}
|
||||||
|
|
||||||
|
max := 2
|
||||||
|
for _, cfg := range lims {
|
||||||
|
frl := cfg.frl
|
||||||
|
ctx := context.Background()
|
||||||
|
t.Run(cfg.name, func(t *testing.T) {
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
ok, err := frl.Take(ctx, "foo", max)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, i < max, ok)
|
||||||
|
ok, err = frl.Take(ctx, "bar", max)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, i < max, ok)
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
ok, _ := frl.Take(ctx, "foo", max)
|
||||||
|
require.Equal(t, i < max, ok)
|
||||||
|
ok, _ = frl.Take(ctx, "bar", max)
|
||||||
|
require.Equal(t, i < max, ok)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user