prorate 0.1.0 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.travis.yml +14 -4
- data/README.md +2 -2
- data/lib/prorate/rate_limit.lua +50 -0
- data/lib/prorate/throttle.rb +46 -9
- data/lib/prorate/version.rb +1 -1
- data/scripts/bm.rb +43 -0
- data/scripts/bm_latency_lb_vs_mget.rb +61 -0
- data/scripts/reload_lua.rb +6 -0
- metadata +6 -5
- data/lib/prorate/block_for.rb +0 -13
- data/lib/prorate/counter.rb +0 -53
- data/lib/prorate/throttled.rb +0 -9
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: db453351faca0b61a4517795368fe719fe5c07bb
|
4
|
+
data.tar.gz: 165c6088be69a4a3b291059aa4e872061e5f999f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b9dd96de6c8915e8ef39f7737e930976d4f83909b8eb861456966ede6a2d62cd82f0b40b634af8da824bbd303d84e83205924d6ceb51369a17e82e6eec01523f
|
7
|
+
data.tar.gz: 5e349bc7288a6da431d9ef7177fc77f2041638ace47fe319af1e533846472fe234e1760ccd3d26bb208ad3e649eb99ecbed7733dc14871f53f63e19dd4b512f7
|
data/.travis.yml
CHANGED
@@ -1,5 +1,15 @@
|
|
1
|
-
sudo: false
|
2
|
-
language: ruby
|
3
1
|
rvm:
|
4
|
-
|
5
|
-
|
2
|
+
- 2.2.5
|
3
|
+
- 2.3.3
|
4
|
+
- 2.4.1
|
5
|
+
|
6
|
+
services:
|
7
|
+
- redis
|
8
|
+
|
9
|
+
dist: trusty # https://docs.travis-ci.com/user/trusty-ci-environment/
|
10
|
+
sudo: false
|
11
|
+
cache: bundler
|
12
|
+
|
13
|
+
# Travis permits the following phases: before_install, install, after_install, before_script, script, after_script
|
14
|
+
script:
|
15
|
+
- bundle exec rspec
|
data/README.md
CHANGED
@@ -1,7 +1,8 @@
|
|
1
1
|
# Prorate
|
2
2
|
|
3
3
|
Provides a low-level time-based throttle. Is mainly meant for situations where using something like Rack::Attack is not very
|
4
|
-
useful since you need access to more variables.
|
4
|
+
useful since you need access to more variables. Under the hood, this uses a Lua script that implements the
|
5
|
+
[Leaky Bucket](https://en.wikipedia.org/wiki/Leaky_bucket) algorithm in a single threaded and race condition safe way.
|
5
6
|
|
6
7
|
## Installation
|
7
8
|
|
@@ -23,7 +24,6 @@ Or install it yourself as:
|
|
23
24
|
|
24
25
|
Within your Rails controller:
|
25
26
|
|
26
|
-
throttle_args[:block_for] ||= throttle_args.fetch(:period)
|
27
27
|
t = Prorate::Throttle.new(redis: Redis.new, logger: Rails.logger,
|
28
28
|
name: "throttle-login-email", limit: 20, period: 5.seconds)
|
29
29
|
# Add all the parameters that function as a discriminator
|
@@ -0,0 +1,50 @@
|
|
1
|
+
-- Single threaded Leaky Bucket implementation.
|
2
|
+
-- args: key_base, leak_rate, max_bucket_capacity, block_duration
|
3
|
+
-- returns: an array of two integers, the first of which indicates the remaining block time.
|
4
|
+
-- if the block time is nonzero, the second integer is always zero. If the block time is zero,
|
5
|
+
-- the second integer indicates the level of the bucket
|
6
|
+
|
7
|
+
-- this is required to be able to use TIME and writes; basically it lifts the script into IO
|
8
|
+
redis.replicate_commands()
|
9
|
+
-- make some nicer looking variable names:
|
10
|
+
local retval = nil
|
11
|
+
local bucket_level_key = ARGV[1] .. ".bucket_level"
|
12
|
+
local last_updated_key = ARGV[1] .. ".last_updated"
|
13
|
+
local block_key = ARGV[1] .. ".block"
|
14
|
+
local max_bucket_capacity = tonumber(ARGV[2])
|
15
|
+
local leak_rate = tonumber(ARGV[3])
|
16
|
+
local block_duration = tonumber(ARGV[4])
|
17
|
+
local now = tonumber(redis.call("TIME")[1]) --unix timestamp, will be required in all paths
|
18
|
+
|
19
|
+
local key_lifetime = math.ceil(max_bucket_capacity / leak_rate)
|
20
|
+
|
21
|
+
local blocked_until = redis.call("GET", block_key)
|
22
|
+
if blocked_until then
|
23
|
+
return {(tonumber(blocked_until) - now), 0}
|
24
|
+
end
|
25
|
+
|
26
|
+
-- get current bucket level
|
27
|
+
local bucket_level = tonumber(redis.call("GET", bucket_level_key))
|
28
|
+
if not bucket_level then
|
29
|
+
-- this throttle/identifier combo does not exist yet, so much calculation can be skipped
|
30
|
+
redis.call("SETEX", bucket_level_key, key_lifetime, 1) -- set bucket with initial value
|
31
|
+
retval = {0, 1}
|
32
|
+
else
|
33
|
+
-- if it already exists, do the leaky bucket thing
|
34
|
+
local last_updated = tonumber(redis.call("GET", last_updated_key)) or now -- use sensible default of 'now' if the key does not exist
|
35
|
+
local new_bucket_level = math.max(0, bucket_level - (leak_rate * (now - last_updated)))
|
36
|
+
|
37
|
+
if (new_bucket_level + 1) <= max_bucket_capacity then
|
38
|
+
new_bucket_level = new_bucket_level + 1
|
39
|
+
retval = {0, math.ceil(new_bucket_level)}
|
40
|
+
else
|
41
|
+
redis.call("SETEX", block_key, block_duration, now + block_duration)
|
42
|
+
retval = {block_duration, 0}
|
43
|
+
end
|
44
|
+
redis.call("SETEX", bucket_level_key, key_lifetime, new_bucket_level) --still needs to be saved
|
45
|
+
end
|
46
|
+
|
47
|
+
-- update last_updated for this bucket, required in all branches
|
48
|
+
redis.call("SETEX", last_updated_key, key_lifetime, now)
|
49
|
+
|
50
|
+
return retval
|
data/lib/prorate/throttle.rb
CHANGED
@@ -1,11 +1,36 @@
|
|
1
1
|
require 'digest'
|
2
2
|
|
3
3
|
module Prorate
|
4
|
+
class Throttled < StandardError
|
5
|
+
attr_reader :retry_in_seconds
|
6
|
+
def initialize(try_again_in)
|
7
|
+
@retry_in_seconds = try_again_in
|
8
|
+
super("Throttled, please lower your temper and try again in #{retry_in_seconds} seconds")
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
class ScriptHashMismatch < StandardError
|
13
|
+
end
|
14
|
+
|
15
|
+
class MisconfiguredThrottle < StandardError
|
16
|
+
end
|
17
|
+
|
4
18
|
class Throttle < Ks.strict(:name, :limit, :period, :block_for, :redis, :logger)
|
19
|
+
|
20
|
+
def self.get_script_hash
|
21
|
+
script_filepath = File.join(__dir__,"rate_limit.lua")
|
22
|
+
script = File.read(script_filepath)
|
23
|
+
Digest::SHA1.hexdigest(script)
|
24
|
+
end
|
25
|
+
|
26
|
+
CURRENT_SCRIPT_HASH = get_script_hash
|
27
|
+
|
5
28
|
def initialize(*)
|
6
29
|
super
|
7
30
|
@discriminators = [name.to_s]
|
8
31
|
self.redis = NullPool.new(redis) unless redis.respond_to?(:with)
|
32
|
+
raise MisconfiguredThrottle if ((period <= 0) || (limit <= 0))
|
33
|
+
@leak_rate = limit.to_f / period # tokens per second;
|
9
34
|
end
|
10
35
|
|
11
36
|
def <<(discriminator)
|
@@ -17,17 +42,29 @@ module Prorate
|
|
17
42
|
identifier = [name, discriminator].join(':')
|
18
43
|
|
19
44
|
redis.with do |r|
|
20
|
-
logger.info { "Checking throttle block %s" % name }
|
21
|
-
raise Throttled.new(block_for) if Prorate::BlockFor.blocked?(id: identifier, redis: r)
|
22
|
-
|
23
45
|
logger.info { "Applying throttle counter %s" % name }
|
24
|
-
|
25
|
-
|
26
|
-
if
|
27
|
-
logger.warn { "Throttle %s exceeded limit of %d
|
28
|
-
|
29
|
-
raise Throttled.new(period)
|
46
|
+
remaining_block_time, bucket_level = run_lua_throttler(redis: r, identifier: identifier, bucket_capacity: limit, leak_rate: @leak_rate, block_for: block_for)
|
47
|
+
|
48
|
+
if remaining_block_time > 0
|
49
|
+
logger.warn { "Throttle %s exceeded limit of %d in %d seconds and is blocked for the next %d seconds" % [name, limit, period, remaining_block_time] }
|
50
|
+
raise Throttled.new(remaining_block_time)
|
30
51
|
end
|
52
|
+
available_calls = limit - bucket_level
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def run_lua_throttler(redis: , identifier: , bucket_capacity: , leak_rate: , block_for: )
|
57
|
+
redis.evalsha(CURRENT_SCRIPT_HASH, [], [identifier, bucket_capacity, leak_rate, block_for])
|
58
|
+
rescue Redis::CommandError => e
|
59
|
+
if e.message.include? "NOSCRIPT"
|
60
|
+
# The Redis server has never seen this script before. Needs to run only once in the entire lifetime of the Redis server (unless the script changes)
|
61
|
+
script_filepath = File.join(__dir__,"rate_limit.lua")
|
62
|
+
script = File.read(script_filepath)
|
63
|
+
raise ScriptHashMismatch if Digest::SHA1.hexdigest(script) != CURRENT_SCRIPT_HASH
|
64
|
+
redis.script(:load, script)
|
65
|
+
redis.evalsha(CURRENT_SCRIPT_HASH, [], [identifier, bucket_capacity, leak_rate, block_for])
|
66
|
+
else
|
67
|
+
raise e
|
31
68
|
end
|
32
69
|
end
|
33
70
|
end
|
data/lib/prorate/version.rb
CHANGED
data/scripts/bm.rb
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
# Runs a mild benchmark and prints out the average time a call to 'throttle!' takes.
|
2
|
+
|
3
|
+
require 'prorate'
|
4
|
+
require 'benchmark'
|
5
|
+
require 'redis'
|
6
|
+
require 'securerandom'
|
7
|
+
|
8
|
+
def average_ms(ary)
|
9
|
+
ary.map{|x| x*1000}.inject(0,&:+) / ary.length
|
10
|
+
end
|
11
|
+
|
12
|
+
r = Redis.new
|
13
|
+
|
14
|
+
logz = Logger.new(STDERR)
|
15
|
+
logz.level = Logger::FATAL # block out most stuff
|
16
|
+
|
17
|
+
times = []
|
18
|
+
50.times do
|
19
|
+
times << Benchmark.realtime {
|
20
|
+
t = Prorate::Throttle.new(redis: r, logger: logz, name: "throttle-login-email", limit: 60, period: 30, block_for: 5)
|
21
|
+
# Add all the parameters that function as a discriminator
|
22
|
+
t << '127.0.2.1'
|
23
|
+
t << 'no_person@nowhere.com'
|
24
|
+
t.throttle!
|
25
|
+
}
|
26
|
+
end
|
27
|
+
|
28
|
+
puts average_ms times
|
29
|
+
|
30
|
+
times = []
|
31
|
+
50.times do
|
32
|
+
email = SecureRandom.hex(20)
|
33
|
+
ip = SecureRandom.hex(10)
|
34
|
+
times << Benchmark.realtime {
|
35
|
+
t = Prorate::Throttle.new(redis: r, logger: logz, name: "throttle-login-email", limit: 30, period: 30, block_for: 5)
|
36
|
+
# Add all the parameters that function as a discriminator
|
37
|
+
t << ip
|
38
|
+
t << email
|
39
|
+
t.throttle!
|
40
|
+
}
|
41
|
+
end
|
42
|
+
|
43
|
+
puts average_ms times
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# Runs a mild benchmark and prints out the average time a call to 'throttle!' takes.
|
2
|
+
|
3
|
+
require 'prorate'
|
4
|
+
require 'benchmark'
|
5
|
+
require 'redis'
|
6
|
+
require 'securerandom'
|
7
|
+
|
8
|
+
def average_ms(ary)
|
9
|
+
ary.map{|x| x*1000}.inject(0,&:+) / ary.length
|
10
|
+
end
|
11
|
+
|
12
|
+
r = Redis.new
|
13
|
+
|
14
|
+
# 4000000.times do
|
15
|
+
# random1 = SecureRandom.hex(10)
|
16
|
+
# random2 = SecureRandom.hex(10)
|
17
|
+
# r.set(random1,random2)
|
18
|
+
# end
|
19
|
+
|
20
|
+
logz = Logger.new(STDERR)
|
21
|
+
logz.level = Logger::FATAL # block out most stuff
|
22
|
+
|
23
|
+
times = []
|
24
|
+
15.times do
|
25
|
+
id = SecureRandom.hex(10)
|
26
|
+
times << Benchmark.realtime {
|
27
|
+
r.evalsha('c95c5f1197cef04ec4afd7d64760f9175933e55a', [], [id, 120, 50, 10]) # values beyond 120 chosen more or less at random
|
28
|
+
}
|
29
|
+
end
|
30
|
+
|
31
|
+
puts average_ms times
|
32
|
+
def key_for_ts(ts)
|
33
|
+
"th:%s:%d" % [@id, ts]
|
34
|
+
end
|
35
|
+
|
36
|
+
times = []
|
37
|
+
15.times do
|
38
|
+
id = SecureRandom.hex(10)
|
39
|
+
sec, _ = r.time # Use Redis time instead of the system timestamp, so that all the nodes are consistent
|
40
|
+
ts = sec.to_i # All Redis results are strings
|
41
|
+
k = key_for_ts(ts)
|
42
|
+
times << Benchmark.realtime {
|
43
|
+
r.multi do |txn|
|
44
|
+
# Increment the counter
|
45
|
+
txn.incr(k)
|
46
|
+
txn.expire(k, 120)
|
47
|
+
|
48
|
+
span_start = ts - 120
|
49
|
+
span_end = ts + 1
|
50
|
+
possible_keys = (span_start..span_end).map{|prev_time| key_for_ts(prev_time) }
|
51
|
+
|
52
|
+
# Fetch all the counter values within the time window. Despite the fact that this
|
53
|
+
# will return thousands of elements for large sliding window sizes, the values are
|
54
|
+
# small and an MGET in Redis is pretty cheap, so perf should stay well within limits.
|
55
|
+
txn.mget(*possible_keys)
|
56
|
+
end
|
57
|
+
}
|
58
|
+
end
|
59
|
+
|
60
|
+
puts average_ms times
|
61
|
+
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: prorate
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Julik Tarkhanov
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2017-
|
11
|
+
date: 2017-07-18 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: ks
|
@@ -111,14 +111,15 @@ files:
|
|
111
111
|
- bin/console
|
112
112
|
- bin/setup
|
113
113
|
- lib/prorate.rb
|
114
|
-
- lib/prorate/block_for.rb
|
115
|
-
- lib/prorate/counter.rb
|
116
114
|
- lib/prorate/null_logger.rb
|
117
115
|
- lib/prorate/null_pool.rb
|
116
|
+
- lib/prorate/rate_limit.lua
|
118
117
|
- lib/prorate/throttle.rb
|
119
|
-
- lib/prorate/throttled.rb
|
120
118
|
- lib/prorate/version.rb
|
121
119
|
- prorate.gemspec
|
120
|
+
- scripts/bm.rb
|
121
|
+
- scripts/bm_latency_lb_vs_mget.rb
|
122
|
+
- scripts/reload_lua.rb
|
122
123
|
homepage: https://github.com/WeTransfer/prorate
|
123
124
|
licenses:
|
124
125
|
- MIT
|
data/lib/prorate/block_for.rb
DELETED
data/lib/prorate/counter.rb
DELETED
@@ -1,53 +0,0 @@
|
|
1
|
-
module Prorate
|
2
|
-
# The counter implements a rolling window throttling mechanism. At each call to incr(), the Redis time
|
3
|
-
# is obtained. A counter then gets set at the key corresponding to the timestamp of the request, with a
|
4
|
-
# granularity of a second. If requests are done continuously and in large volume, the counter will therefore
|
5
|
-
# create one key for each second of the given rolling window size. he counters per second are set to auto-expire
|
6
|
-
# after the window lapses. When incr() is performed, there is
|
7
|
-
class Counter
|
8
|
-
def initialize(redis:, logger: NullLogger, id:, window_size:)
|
9
|
-
@redis = redis
|
10
|
-
@logger = logger
|
11
|
-
@id = id
|
12
|
-
@in_span_of_seconds = window_size.to_i.abs
|
13
|
-
end
|
14
|
-
|
15
|
-
# Increments the throttle counter for this identifier, and returns the total number of requests
|
16
|
-
# performed so far within the given time span. The caller can then determine whether the request has
|
17
|
-
# to be throttled or can be let through.
|
18
|
-
def incr
|
19
|
-
sec, _ = @redis.time # Use Redis time instead of the system timestamp, so that all the nodes are consistent
|
20
|
-
ts = sec.to_i # All Redis results are strings
|
21
|
-
k = key_for_ts(ts)
|
22
|
-
# Do the Redis stuff in a transaction, and capture only the necessary values
|
23
|
-
# (the result of MULTI is all the return values of each call in sequence)
|
24
|
-
*_, done_last_second, _, counter_values = @redis.multi do |txn|
|
25
|
-
# Increment the counter
|
26
|
-
txn.incr(k)
|
27
|
-
txn.expire(k, @in_span_of_seconds)
|
28
|
-
|
29
|
-
span_start = ts - @in_span_of_seconds
|
30
|
-
span_end = ts + 1
|
31
|
-
possible_keys = (span_start..span_end).map{|prev_time| key_for_ts(prev_time) }
|
32
|
-
@logger.debug { "%s: Scanning %d possible keys" % [@id, possible_keys.length] }
|
33
|
-
|
34
|
-
# Fetch all the counter values within the time window. Despite the fact that this
|
35
|
-
# will return thousands of elements for large sliding window sizes, the values are
|
36
|
-
# small and an MGET in Redis is pretty cheap, so perf should stay well within limits.
|
37
|
-
txn.mget(*possible_keys)
|
38
|
-
end
|
39
|
-
|
40
|
-
# Sum all the values. The empty keys return nils from MGET, which become 0 on to_i casts.
|
41
|
-
total_requests_during_period = counter_values.map(&:to_i).inject(&:+)
|
42
|
-
@logger.debug { "%s: %d reqs total during the last %d seconds" % [@id, total_requests_during_period, @in_span_of_seconds] }
|
43
|
-
|
44
|
-
total_requests_during_period
|
45
|
-
end
|
46
|
-
|
47
|
-
private
|
48
|
-
|
49
|
-
def key_for_ts(ts)
|
50
|
-
"th:%s:%d" % [@id, ts]
|
51
|
-
end
|
52
|
-
end
|
53
|
-
end
|
data/lib/prorate/throttled.rb
DELETED