prorate 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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