praroter 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +9 -0
- data/.rspec +2 -0
- data/.rubocop.yml +2 -0
- data/.travis.yml +17 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +198 -0
- data/Rakefile +18 -0
- data/bin/console +16 -0
- data/bin/setup +6 -0
- data/docker-compose.yml +7 -0
- data/lib/praroter.rb +8 -0
- data/lib/praroter/filly_bucket.lua +57 -0
- data/lib/praroter/filly_bucket.rb +107 -0
- data/lib/praroter/null_logger.rb +15 -0
- data/lib/praroter/null_pool.rb +7 -0
- data/lib/praroter/throttled.rb +17 -0
- data/lib/praroter/version.rb +3 -0
- data/praroter.gemspec +40 -0
- data/scripts/bm.rb +36 -0
- data/scripts/bm_latency_lb_vs_mget.rb +61 -0
- data/scripts/test.sh +2 -0
- metadata +213 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: a107a6850338e77ecac23ff79c3ec477fbdf6615cb6b58a705e2f575752d2493
|
4
|
+
data.tar.gz: 595c3f911778b4fd1a8a6dd64da6e8c7b0185db0c3ca6fc34bfc3b6b4926f719
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 6bb75c4e204bb148c9f7928689c3bf7c0d5c8fb356820a34a6a0160fd7f97606914ae73b561fa9ec3bdb5d1862028ce778e01e5e8145e5f7d7bd1f9eb1d11f72
|
7
|
+
data.tar.gz: 99192564d7291ede39f07d5d7ee8c0f8c780c37f3faa45726ab141f8987445d5fa8b6c1eeb346cddb29de5e13e6bd241151d5080e26d3135a8a78a1e2f83a463
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.rubocop.yml
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2017 Julik Tarkhanov
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,198 @@
|
|
1
|
+
# Praroter
|
2
|
+
|
3
|
+
This is built on top of, and forked from the excellent gem named Prorate by WeTransfer: https://github.com/WeTransfer/prorate
|
4
|
+
|
5
|
+
It was forked because we had slightly different needs for our endpoints:
|
6
|
+
|
7
|
+
- We bill calls based on how long the request takes (Prorate is built to bill per requests)
|
8
|
+
- We only know how long the request took by the end of the request cycle so we have to bill after the work is done (Prorate bills in the beginning of the request)
|
9
|
+
- Because we bill by the end of the request, we allow consumers to "owe" us time, that they have to pay back by waiting longer.
|
10
|
+
|
11
|
+
## Installation
|
12
|
+
|
13
|
+
Add this line to your application's Gemfile:
|
14
|
+
|
15
|
+
```ruby
|
16
|
+
gem 'praroter'
|
17
|
+
```
|
18
|
+
|
19
|
+
And then execute:
|
20
|
+
|
21
|
+
```shell
|
22
|
+
bundle install
|
23
|
+
```
|
24
|
+
|
25
|
+
Or install it yourself as:
|
26
|
+
|
27
|
+
```shell
|
28
|
+
gem install praroter
|
29
|
+
```
|
30
|
+
|
31
|
+
## Implementation
|
32
|
+
|
33
|
+
The simplest mode of operation is throttling an endpoint, this is done by:
|
34
|
+
|
35
|
+
- 1. First check if the bucket is empty
|
36
|
+
- 2. Then do work
|
37
|
+
- 3. Drain the amount of work done from bucket
|
38
|
+
|
39
|
+
## Naïve Rails implementation
|
40
|
+
|
41
|
+
Within your Rails controller:
|
42
|
+
|
43
|
+
```ruby
|
44
|
+
def index
|
45
|
+
# 1. First check if the bucket is empty
|
46
|
+
# -----------------------------------------------------------
|
47
|
+
redis = Redis.new
|
48
|
+
rate_limiter = Praroter::FillyBucket::Creator.new(redis: redis)
|
49
|
+
bucket = rate_limiter.setup_bucket(
|
50
|
+
key: [request.ip, params.require(:email)].join,
|
51
|
+
fill_rate: 2, # per second
|
52
|
+
capacity: 20 # default, acts as a buffer
|
53
|
+
)
|
54
|
+
bucket.throttle! # This will throw Prarotor::Throttled if level is negative
|
55
|
+
request_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
56
|
+
|
57
|
+
# 2. Then do work
|
58
|
+
# -----------------------------------------------------------
|
59
|
+
sleep(2.242)
|
60
|
+
|
61
|
+
# 3. Drain the amount of work from bucket
|
62
|
+
# -----------------------------------------------------------
|
63
|
+
request_end = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
64
|
+
request_diff = ((request_end - request_start) * 1000).to_i
|
65
|
+
bucket.drain(request_diff)
|
66
|
+
|
67
|
+
render plain: "Home"
|
68
|
+
end
|
69
|
+
```
|
70
|
+
|
71
|
+
To capture that exception, add this to the controller:
|
72
|
+
|
73
|
+
```ruby
|
74
|
+
rescue_from Praroter::Throttled do |e|
|
75
|
+
response.set_header('X-Ratelimit-Level', e.bucket_state.level)
|
76
|
+
response.set_header('X-Ratelimit-Capacity', e.bucket_state.capacity)
|
77
|
+
response.set_header('X-Ratelimit-Retry-After', e.retry_in_seconds)
|
78
|
+
render nothing: true, status: 429
|
79
|
+
end
|
80
|
+
```
|
81
|
+
|
82
|
+
## Prettier Rails implementation
|
83
|
+
|
84
|
+
Within your initializers:
|
85
|
+
|
86
|
+
```ruby
|
87
|
+
require 'prarotor'
|
88
|
+
|
89
|
+
redis = Redis.new
|
90
|
+
Rails.configuration.rate_limiter = Praroter::FillyBucket::Creator.new(redis: redis)
|
91
|
+
```
|
92
|
+
|
93
|
+
Within your Rails controller:
|
94
|
+
|
95
|
+
```ruby
|
96
|
+
def index
|
97
|
+
# 1. First check if the bucket is empty
|
98
|
+
# -----------------------------------------------------------
|
99
|
+
ratelimit_bucket.throttle!
|
100
|
+
|
101
|
+
# 3. Drain the amount of work from bucket
|
102
|
+
# -----------------------------------------------------------
|
103
|
+
ratelimit_bucket.drain_block do
|
104
|
+
# 2. Then do work
|
105
|
+
# ---------------------------------------------------------
|
106
|
+
sleep(2.242)
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
protected
|
111
|
+
|
112
|
+
def ratelimit_bucket
|
113
|
+
@ratelimit_bucket ||= Rails.configuration.rate_limiter.setup_bucket(
|
114
|
+
key: [request.ip, params.require(:email)].join,
|
115
|
+
fill_rate: 2, # per second
|
116
|
+
capacity: 20 # default, acts as a buffer
|
117
|
+
)
|
118
|
+
end
|
119
|
+
```
|
120
|
+
|
121
|
+
## Perfect Rails implementation
|
122
|
+
|
123
|
+
Within your initializers:
|
124
|
+
|
125
|
+
```ruby
|
126
|
+
require 'prarotor'
|
127
|
+
|
128
|
+
redis = Redis.new
|
129
|
+
Rails.configuration.rate_limiter = Praroter::FillyBucket::Creator.new(redis: redis)
|
130
|
+
```
|
131
|
+
|
132
|
+
Within your Rails controller:
|
133
|
+
|
134
|
+
```ruby
|
135
|
+
around_action :api_ratelimit
|
136
|
+
|
137
|
+
def index
|
138
|
+
# 2. Then do work
|
139
|
+
# ---------------------------------------------------------
|
140
|
+
sleep(2.242)
|
141
|
+
end
|
142
|
+
|
143
|
+
rescue_from Praroter::FillyBucket::Throttled do |e|
|
144
|
+
response.set_header('X-Ratelimit-Level', e.bucket_state.level)
|
145
|
+
response.set_header('X-Ratelimit-Capacity', e.bucket_state.capacity)
|
146
|
+
response.set_header('X-Ratelimit-Retry-After', e.retry_in_seconds)
|
147
|
+
render nothing: true, status: 429
|
148
|
+
end
|
149
|
+
|
150
|
+
protected
|
151
|
+
|
152
|
+
def api_ratelimit
|
153
|
+
# 1. First check if the bucket is empty
|
154
|
+
# -----------------------------------------------------------
|
155
|
+
ratelimit_bucket.throttle!
|
156
|
+
|
157
|
+
# 3. Drain the amount of work from bucket
|
158
|
+
# -----------------------------------------------------------
|
159
|
+
bucket_state = ratelimit_bucket.drain_block do
|
160
|
+
yield
|
161
|
+
end
|
162
|
+
response.set_header('X-Ratelimit-Level', bucket_state.level)
|
163
|
+
response.set_header('X-Ratelimit-Capacity', bucket_state.capacity)
|
164
|
+
end
|
165
|
+
|
166
|
+
def ratelimit_bucket
|
167
|
+
@ratelimit_bucket ||= Rails.configuration.rate_limiter.setup_bucket(
|
168
|
+
key: [request.ip, params.require(:email)].join,
|
169
|
+
fill_rate: 2, # per second
|
170
|
+
capacity: 20 # default, acts as a buffer
|
171
|
+
)
|
172
|
+
end
|
173
|
+
```
|
174
|
+
|
175
|
+
## Why Lua?
|
176
|
+
|
177
|
+
Praroter is a fork of Prorate, here's what they are saying about the choice of Lua:
|
178
|
+
|
179
|
+
Prorate is implementing throttling using the "Leaky Bucket" algorithm and is extensively described [here](https://github.com/WeTransfer/prorate/blob/master/lib/prorate/throttle.rb). The implementation is using a Lua script, because is the only language available which runs _inside_ Redis. Thanks to the speed benefits of Lua the script runs fast enough to apply it on every throttle call.
|
180
|
+
|
181
|
+
Using a Lua script in Prorate helps us achieve the following guarantees:
|
182
|
+
|
183
|
+
- **The script will run atomically.** The script is evaluated as a single Redis command. This ensures that the commands in the Lua script will never be interleaved with another client: they will always execute together.
|
184
|
+
- **Any usages of time will use the Redis time.** Throttling requires a consistent and monotonic _time source_. The only monotonic and consistent time source which is usable in the context of Prorate, is the `TIME` result of Redis itself. We are throttling requests from different machines, which will invariably have clock drift between them. This way using the Redis server `TIME` helps achieve consistency.
|
185
|
+
|
186
|
+
## Development
|
187
|
+
|
188
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
189
|
+
|
190
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
191
|
+
|
192
|
+
## Contributing
|
193
|
+
|
194
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/kaspergrubbe/praroter.
|
195
|
+
|
196
|
+
## License
|
197
|
+
|
198
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
2
|
+
require "rspec/core/rake_task"
|
3
|
+
require 'rubocop/rake_task'
|
4
|
+
require 'yard'
|
5
|
+
|
6
|
+
YARD::Rake::YardocTask.new(:doc) do |t|
|
7
|
+
# The dash has to be between the two to "divide" the source files and
|
8
|
+
# miscellaneous documentation files that contain no code
|
9
|
+
t.files = ['lib/**/*.rb', '-', 'LICENSE.txt', 'CHANGELOG.md']
|
10
|
+
end
|
11
|
+
|
12
|
+
RSpec::Core::RakeTask.new(:spec) do |spec|
|
13
|
+
spec.rspec_opts = ["-c", "--order=rand"]
|
14
|
+
spec.pattern = FileList['spec/**/*_spec.rb']
|
15
|
+
end
|
16
|
+
|
17
|
+
RuboCop::RakeTask.new(:rubocop)
|
18
|
+
task default: [:spec, :rubocop]
|
data/bin/console
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "praroter"
|
5
|
+
|
6
|
+
require 'connection_pool'
|
7
|
+
@redis_pool = ConnectionPool.new(size: 5, timeout: 5) do
|
8
|
+
Redis.new(
|
9
|
+
id: "Praroter-#{Thread.current.object_id}-PID-#{Process.pid}",
|
10
|
+
tcp_keepalive: 30,
|
11
|
+
reconnect_attempts: 1
|
12
|
+
)
|
13
|
+
end
|
14
|
+
|
15
|
+
require "pry"
|
16
|
+
Pry.start
|
data/bin/setup
ADDED
data/docker-compose.yml
ADDED
data/lib/praroter.rb
ADDED
@@ -0,0 +1,57 @@
|
|
1
|
+
-- this is required to be able to use TIME and writes; basically it lifts the script into IO
|
2
|
+
redis.replicate_commands()
|
3
|
+
|
4
|
+
-- Redis documentation recommends passing the keys separately so that Redis
|
5
|
+
-- can - in the future - verify that they live on the same shard of a cluster, and
|
6
|
+
-- raise an error if they are not. As far as can be understood this functionality is not
|
7
|
+
-- yet present, but if we can make a little effort to make ourselves more future proof
|
8
|
+
-- we should.
|
9
|
+
local bucket_level_key = KEYS[1]
|
10
|
+
local last_updated_key = KEYS[2]
|
11
|
+
|
12
|
+
local bucket_capacity = tonumber(ARGV[1]) -- How many tokens is the bucket allowed to contain
|
13
|
+
local fill_rate = tonumber(ARGV[2])
|
14
|
+
local scoop = tonumber(ARGV[3]) -- How many tokens to remove
|
15
|
+
|
16
|
+
-- Take a timestamp
|
17
|
+
local redis_time = redis.call("TIME") -- Array of [seconds, microseconds]
|
18
|
+
local now = tonumber(redis_time[1]) + (tonumber(redis_time[2]) / 1000000)
|
19
|
+
|
20
|
+
-- get current bucket level. The throttle key might not exist yet in which
|
21
|
+
-- case we default to bucket_capacity
|
22
|
+
local bucket_level = tonumber(redis.call("GET", bucket_level_key)) or bucket_capacity
|
23
|
+
|
24
|
+
-- ...and then perform the leaky bucket fillup/leak. We need to do this also when the bucket has
|
25
|
+
-- just been created because the initial fillup to add might be so high that it will
|
26
|
+
-- immediately overflow the bucket and trigger the throttle, on the first call.
|
27
|
+
local last_updated = tonumber(redis.call("GET", last_updated_key)) or now -- use sensible default of 'now' if the key does not exist
|
28
|
+
|
29
|
+
-- Add the number of tokens dripped since last call
|
30
|
+
local dt = now - last_updated
|
31
|
+
local new_bucket_level = bucket_level + (fill_rate * dt) - scoop
|
32
|
+
|
33
|
+
-- and _then_ and add the tokens we fillup with
|
34
|
+
new_bucket_level = math.min(bucket_capacity, new_bucket_level)
|
35
|
+
|
36
|
+
-- Compute the key TTL for the bucket. We are interested in how long it takes the bucket
|
37
|
+
-- to leak all the way to bucket_capacity, as this is the time when the values stay relevant. We pad with 1 second
|
38
|
+
-- to have a little cushion.
|
39
|
+
local key_lifetime = nil
|
40
|
+
if new_bucket_level < 0 then -- if new_bucket_level is negative, then the TTL need to be longer
|
41
|
+
key_lifetime = math.ceil((math.abs(bucket_capacity - new_bucket_level) / fill_rate) + 1)
|
42
|
+
else
|
43
|
+
key_lifetime = math.ceil((bucket_capacity / fill_rate) + 1)
|
44
|
+
end
|
45
|
+
|
46
|
+
if new_bucket_level == bucket_capacity then
|
47
|
+
return {new_bucket_level, bucket_capacity, fill_rate}
|
48
|
+
else
|
49
|
+
-- Save the new bucket level
|
50
|
+
redis.call("SETEX", bucket_level_key, key_lifetime, new_bucket_level)
|
51
|
+
|
52
|
+
-- Record when we updated the bucket so that the amount of tokens leaked
|
53
|
+
-- can be correctly determined on the next invocation
|
54
|
+
redis.call("SETEX", last_updated_key, key_lifetime, now)
|
55
|
+
|
56
|
+
return {new_bucket_level, bucket_capacity, fill_rate}
|
57
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
module Praroter
|
2
|
+
|
3
|
+
module FillyBucket
|
4
|
+
|
5
|
+
class BucketState < Struct.new(:level, :capacity, :fill_rate)
|
6
|
+
def empty?
|
7
|
+
level <= 0
|
8
|
+
end
|
9
|
+
|
10
|
+
def full?
|
11
|
+
level >= capacity
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
class Bucket
|
16
|
+
attr_reader :key, :fill_rate, :capacity
|
17
|
+
|
18
|
+
def initialize(key, fill_rate, capacity, creator)
|
19
|
+
@key = key
|
20
|
+
@fill_rate = fill_rate
|
21
|
+
@capacity = capacity
|
22
|
+
@creator = creator
|
23
|
+
end
|
24
|
+
|
25
|
+
def state
|
26
|
+
@creator.run_lua_bucket_script(self, 0)
|
27
|
+
end
|
28
|
+
|
29
|
+
def empty?
|
30
|
+
state.empty?
|
31
|
+
end
|
32
|
+
|
33
|
+
def full?
|
34
|
+
state.full?
|
35
|
+
end
|
36
|
+
|
37
|
+
def throttle!
|
38
|
+
bucket_state = state
|
39
|
+
if bucket_state.empty?
|
40
|
+
remaining_block_time = ((bucket_state.capacity - bucket_state.level).abs / bucket_state.fill_rate) + 3
|
41
|
+
raise Praroter::Throttled.new(bucket_state, remaining_block_time)
|
42
|
+
end
|
43
|
+
bucket_state
|
44
|
+
end
|
45
|
+
|
46
|
+
def drain(amount)
|
47
|
+
raise ArgumentError, "drain amount must be positive" if amount < 0
|
48
|
+
@creator.run_lua_bucket_script(self, amount)
|
49
|
+
end
|
50
|
+
|
51
|
+
def drain_block
|
52
|
+
work_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
53
|
+
yield
|
54
|
+
work_end = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
55
|
+
drain(((work_end - work_start) * 1000).to_i)
|
56
|
+
end
|
57
|
+
|
58
|
+
def level_key
|
59
|
+
"filly_bucket.#{key}.bucket_level"
|
60
|
+
end
|
61
|
+
|
62
|
+
def last_updated_key
|
63
|
+
"filly_bucket.#{key}.last_updated"
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
class Creator
|
68
|
+
LUA_SCRIPT_CODE = File.read(File.join(__dir__, "filly_bucket.lua"))
|
69
|
+
LUA_SCRIPT_HASH = Digest::SHA1.hexdigest(LUA_SCRIPT_CODE)
|
70
|
+
|
71
|
+
def initialize(redis:)
|
72
|
+
@redis = redis.respond_to?(:with) ? redis : NullPool.new(redis)
|
73
|
+
end
|
74
|
+
|
75
|
+
def setup_bucket(key:, fill_rate:, capacity:)
|
76
|
+
Praroter::FillyBucket::Bucket.new(key, fill_rate, capacity, self)
|
77
|
+
end
|
78
|
+
|
79
|
+
def run_lua_bucket_script(bucket, amount)
|
80
|
+
@redis.with do |r|
|
81
|
+
begin
|
82
|
+
# The script returns a tuple of "whole tokens, microtokens"
|
83
|
+
# to be able to smuggle the float across (similar to Redis TIME command)
|
84
|
+
new_bucket_level, bucket_capacity, fill_rate = r.evalsha(
|
85
|
+
LUA_SCRIPT_HASH,
|
86
|
+
keys: [bucket.level_key, bucket.last_updated_key],
|
87
|
+
argv: [bucket.capacity, bucket.fill_rate, amount]
|
88
|
+
)
|
89
|
+
BucketState.new(new_bucket_level, bucket_capacity, fill_rate)
|
90
|
+
rescue Redis::CommandError => e
|
91
|
+
if e.message.include? "NOSCRIPT"
|
92
|
+
# The Redis server has never seen this script before. Needs to run only once in the entire lifetime
|
93
|
+
# of the Redis server, until the script changes - in which case it will be loaded under a different SHA
|
94
|
+
r.script(:load, LUA_SCRIPT_CODE)
|
95
|
+
retry
|
96
|
+
else
|
97
|
+
raise e
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
end
|
104
|
+
|
105
|
+
end
|
106
|
+
|
107
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# The Throttled exception gets raised when a throttle is triggered.
|
2
|
+
#
|
3
|
+
# The exception carries additional attributes which can be used for
|
4
|
+
# error tracking and for creating a correct Retry-After HTTP header for
|
5
|
+
# a 429 response
|
6
|
+
class Praroter::Throttled < StandardError
|
7
|
+
# @attr [Integer] for how long the caller will be blocked, in seconds.
|
8
|
+
attr_reader :retry_in_seconds
|
9
|
+
|
10
|
+
attr_reader :bucket_state
|
11
|
+
|
12
|
+
def initialize(bucket_state, try_again_in)
|
13
|
+
@bucket_state = bucket_state
|
14
|
+
@retry_in_seconds = try_again_in
|
15
|
+
super("Throttled, please lower your temper and try again in #{retry_in_seconds} seconds")
|
16
|
+
end
|
17
|
+
end
|
data/praroter.gemspec
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
lib = File.expand_path('../lib', __FILE__)
|
2
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
3
|
+
require 'praroter/version'
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "praroter"
|
7
|
+
spec.version = Praroter::VERSION
|
8
|
+
spec.authors = ["Kasper Grubbe", "Julik Tarkhanov"]
|
9
|
+
spec.email = ["praroter@kaspergrubbe.com"]
|
10
|
+
|
11
|
+
spec.summary = %q{Time-restricted rate limiter using Redis}
|
12
|
+
spec.description = %q{Can be used to implement all kinds of throttles}
|
13
|
+
spec.homepage = "https://github.com/kaspergrubbe/praroter"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
# Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
|
17
|
+
# to allow pushing to a single host or delete this section to allow pushing to any host.
|
18
|
+
if spec.respond_to?(:metadata)
|
19
|
+
spec.metadata['allowed_push_host'] = "https://rubygems.org"
|
20
|
+
else
|
21
|
+
raise "RubyGems 2.0 or newer is required to protect against public gem pushes."
|
22
|
+
end
|
23
|
+
|
24
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
25
|
+
spec.bindir = "exe"
|
26
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
27
|
+
spec.require_paths = ["lib"]
|
28
|
+
|
29
|
+
spec.add_dependency "redis", '~> 4', '>= 4'
|
30
|
+
spec.add_development_dependency "connection_pool", "~> 2"
|
31
|
+
spec.add_development_dependency "bundler", "~> 2"
|
32
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
33
|
+
spec.add_development_dependency "rspec", "~> 3.0"
|
34
|
+
spec.add_development_dependency 'wetransfer_style', '0.6.5'
|
35
|
+
spec.add_development_dependency 'yard', '~> 0.9'
|
36
|
+
spec.add_development_dependency 'pry', '~> 0.13.1'
|
37
|
+
|
38
|
+
spec.add_development_dependency 'rails', '~> 6.0.3'
|
39
|
+
spec.add_development_dependency 'rspec-rails', '~> 4'
|
40
|
+
end
|
data/scripts/bm.rb
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
# Runs a mild benchmark and prints out the average time a call to 'throttle!' takes.
|
2
|
+
|
3
|
+
require 'praroter'
|
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
|
+
redis = Redis.new
|
13
|
+
|
14
|
+
times = []
|
15
|
+
50.times do
|
16
|
+
times << Benchmark.realtime {
|
17
|
+
rl = Praroter::FillyBucket::Creator.new(redis: redis)
|
18
|
+
b = rl.setup_bucket(key: "throttle-login-email", capacity: 60, fill_rate: 2)
|
19
|
+
b.throttle!
|
20
|
+
}
|
21
|
+
end
|
22
|
+
|
23
|
+
puts average_ms times
|
24
|
+
|
25
|
+
times = []
|
26
|
+
50.times do
|
27
|
+
email = SecureRandom.hex(20)
|
28
|
+
ip = SecureRandom.hex(10)
|
29
|
+
times << Benchmark.realtime {
|
30
|
+
rl = Praroter::FillyBucket::Creator.new(redis: redis)
|
31
|
+
b = rl.setup_bucket(key: "#{email}-#{ip}", capacity: 60, fill_rate: 2)
|
32
|
+
b.throttle!
|
33
|
+
}
|
34
|
+
end
|
35
|
+
|
36
|
+
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 'praroter'
|
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
|
+
redis = Redis.new
|
13
|
+
|
14
|
+
script_path = File.join(__dir__, "lib", "praroter", "filly_bucket.lua").gsub("/scripts", "")
|
15
|
+
LUA_SCRIPT_CODE = File.read(script_path)
|
16
|
+
LUA_SCRIPT_HASH = Digest::SHA1.hexdigest(LUA_SCRIPT_CODE)
|
17
|
+
redis_script_hash = redis.script(:load, LUA_SCRIPT_CODE)
|
18
|
+
|
19
|
+
raise "LUA/REDIS SCRIPT MISMATCH" if LUA_SCRIPT_HASH != redis_script_hash
|
20
|
+
|
21
|
+
times = []
|
22
|
+
15.times do
|
23
|
+
times << Benchmark.realtime {
|
24
|
+
key = "api"
|
25
|
+
redis.evalsha(
|
26
|
+
redis_script_hash,
|
27
|
+
keys: ["filly_bucket.#{key}.bucket_level", "filly_bucket.#{key}.last_updated"],
|
28
|
+
argv: [120, 50, 10]
|
29
|
+
)
|
30
|
+
}
|
31
|
+
end
|
32
|
+
|
33
|
+
puts average_ms times
|
34
|
+
def key_for_ts(ts)
|
35
|
+
"th:%s:%d" % [@id, ts]
|
36
|
+
end
|
37
|
+
|
38
|
+
times = []
|
39
|
+
15.times do
|
40
|
+
sec, _ = redis.time # Use Redis time instead of the system timestamp, so that all the nodes are consistent
|
41
|
+
ts = sec.to_i # All Redis results are strings
|
42
|
+
k = key_for_ts(ts)
|
43
|
+
times << Benchmark.realtime {
|
44
|
+
redis.multi do |txn|
|
45
|
+
# Increment the counter
|
46
|
+
txn.incr(k)
|
47
|
+
txn.expire(k, 120)
|
48
|
+
|
49
|
+
span_start = ts - 120
|
50
|
+
span_end = ts + 1
|
51
|
+
possible_keys = (span_start..span_end).map { |prev_time| key_for_ts(prev_time) }
|
52
|
+
|
53
|
+
# Fetch all the counter values within the time window. Despite the fact that this
|
54
|
+
# will return thousands of elements for large sliding window sizes, the values are
|
55
|
+
# small and an MGET in Redis is pretty cheap, so perf should stay well within limits.
|
56
|
+
txn.mget(*possible_keys)
|
57
|
+
end
|
58
|
+
}
|
59
|
+
end
|
60
|
+
|
61
|
+
puts average_ms times
|
data/scripts/test.sh
ADDED
metadata
ADDED
@@ -0,0 +1,213 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: praroter
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Kasper Grubbe
|
8
|
+
- Julik Tarkhanov
|
9
|
+
autorequire:
|
10
|
+
bindir: exe
|
11
|
+
cert_chain: []
|
12
|
+
date: 2020-11-30 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: redis
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
requirements:
|
18
|
+
- - ">="
|
19
|
+
- !ruby/object:Gem::Version
|
20
|
+
version: '4'
|
21
|
+
- - "~>"
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: '4'
|
24
|
+
type: :runtime
|
25
|
+
prerelease: false
|
26
|
+
version_requirements: !ruby/object:Gem::Requirement
|
27
|
+
requirements:
|
28
|
+
- - ">="
|
29
|
+
- !ruby/object:Gem::Version
|
30
|
+
version: '4'
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '4'
|
34
|
+
- !ruby/object:Gem::Dependency
|
35
|
+
name: connection_pool
|
36
|
+
requirement: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '2'
|
41
|
+
type: :development
|
42
|
+
prerelease: false
|
43
|
+
version_requirements: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '2'
|
48
|
+
- !ruby/object:Gem::Dependency
|
49
|
+
name: bundler
|
50
|
+
requirement: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '2'
|
55
|
+
type: :development
|
56
|
+
prerelease: false
|
57
|
+
version_requirements: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '2'
|
62
|
+
- !ruby/object:Gem::Dependency
|
63
|
+
name: rake
|
64
|
+
requirement: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '13.0'
|
69
|
+
type: :development
|
70
|
+
prerelease: false
|
71
|
+
version_requirements: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '13.0'
|
76
|
+
- !ruby/object:Gem::Dependency
|
77
|
+
name: rspec
|
78
|
+
requirement: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '3.0'
|
83
|
+
type: :development
|
84
|
+
prerelease: false
|
85
|
+
version_requirements: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '3.0'
|
90
|
+
- !ruby/object:Gem::Dependency
|
91
|
+
name: wetransfer_style
|
92
|
+
requirement: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - '='
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: 0.6.5
|
97
|
+
type: :development
|
98
|
+
prerelease: false
|
99
|
+
version_requirements: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - '='
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: 0.6.5
|
104
|
+
- !ruby/object:Gem::Dependency
|
105
|
+
name: yard
|
106
|
+
requirement: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - "~>"
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0.9'
|
111
|
+
type: :development
|
112
|
+
prerelease: false
|
113
|
+
version_requirements: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - "~>"
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0.9'
|
118
|
+
- !ruby/object:Gem::Dependency
|
119
|
+
name: pry
|
120
|
+
requirement: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - "~>"
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: 0.13.1
|
125
|
+
type: :development
|
126
|
+
prerelease: false
|
127
|
+
version_requirements: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - "~>"
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: 0.13.1
|
132
|
+
- !ruby/object:Gem::Dependency
|
133
|
+
name: rails
|
134
|
+
requirement: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - "~>"
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: 6.0.3
|
139
|
+
type: :development
|
140
|
+
prerelease: false
|
141
|
+
version_requirements: !ruby/object:Gem::Requirement
|
142
|
+
requirements:
|
143
|
+
- - "~>"
|
144
|
+
- !ruby/object:Gem::Version
|
145
|
+
version: 6.0.3
|
146
|
+
- !ruby/object:Gem::Dependency
|
147
|
+
name: rspec-rails
|
148
|
+
requirement: !ruby/object:Gem::Requirement
|
149
|
+
requirements:
|
150
|
+
- - "~>"
|
151
|
+
- !ruby/object:Gem::Version
|
152
|
+
version: '4'
|
153
|
+
type: :development
|
154
|
+
prerelease: false
|
155
|
+
version_requirements: !ruby/object:Gem::Requirement
|
156
|
+
requirements:
|
157
|
+
- - "~>"
|
158
|
+
- !ruby/object:Gem::Version
|
159
|
+
version: '4'
|
160
|
+
description: Can be used to implement all kinds of throttles
|
161
|
+
email:
|
162
|
+
- praroter@kaspergrubbe.com
|
163
|
+
executables: []
|
164
|
+
extensions: []
|
165
|
+
extra_rdoc_files: []
|
166
|
+
files:
|
167
|
+
- ".gitignore"
|
168
|
+
- ".rspec"
|
169
|
+
- ".rubocop.yml"
|
170
|
+
- ".travis.yml"
|
171
|
+
- Gemfile
|
172
|
+
- LICENSE.txt
|
173
|
+
- README.md
|
174
|
+
- Rakefile
|
175
|
+
- bin/console
|
176
|
+
- bin/setup
|
177
|
+
- docker-compose.yml
|
178
|
+
- lib/praroter.rb
|
179
|
+
- lib/praroter/filly_bucket.lua
|
180
|
+
- lib/praroter/filly_bucket.rb
|
181
|
+
- lib/praroter/null_logger.rb
|
182
|
+
- lib/praroter/null_pool.rb
|
183
|
+
- lib/praroter/throttled.rb
|
184
|
+
- lib/praroter/version.rb
|
185
|
+
- praroter.gemspec
|
186
|
+
- scripts/bm.rb
|
187
|
+
- scripts/bm_latency_lb_vs_mget.rb
|
188
|
+
- scripts/test.sh
|
189
|
+
homepage: https://github.com/kaspergrubbe/praroter
|
190
|
+
licenses:
|
191
|
+
- MIT
|
192
|
+
metadata:
|
193
|
+
allowed_push_host: https://rubygems.org
|
194
|
+
post_install_message:
|
195
|
+
rdoc_options: []
|
196
|
+
require_paths:
|
197
|
+
- lib
|
198
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
199
|
+
requirements:
|
200
|
+
- - ">="
|
201
|
+
- !ruby/object:Gem::Version
|
202
|
+
version: '0'
|
203
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
204
|
+
requirements:
|
205
|
+
- - ">="
|
206
|
+
- !ruby/object:Gem::Version
|
207
|
+
version: '0'
|
208
|
+
requirements: []
|
209
|
+
rubygems_version: 3.0.3
|
210
|
+
signing_key:
|
211
|
+
specification_version: 4
|
212
|
+
summary: Time-restricted rate limiter using Redis
|
213
|
+
test_files: []
|