praroter 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -0,0 +1,9 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
@@ -0,0 +1,2 @@
1
+ inherit_gem:
2
+ wetransfer_style: ruby/default.yml
@@ -0,0 +1,17 @@
1
+ rvm:
2
+ - 2.2
3
+ - 2.3
4
+ - 2.4
5
+ - 2.5
6
+ - 2.6
7
+ - 2.7
8
+
9
+ services:
10
+ - redis
11
+
12
+ dist: trusty # https://docs.travis-ci.com/user/trusty-ci-environment/
13
+ sudo: false
14
+ cache: bundler
15
+
16
+ script:
17
+ - bundle exec rake
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in praroter.gemspec
4
+ gemspec
@@ -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.
@@ -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).
@@ -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]
@@ -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
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
@@ -0,0 +1,7 @@
1
+ version: "3.8"
2
+
3
+ services:
4
+ redis:
5
+ image: redis:6.0.9-alpine
6
+ ports:
7
+ - "6379:6379"
@@ -0,0 +1,8 @@
1
+ require "praroter/version"
2
+ require "redis"
3
+
4
+ module Praroter
5
+ Dir.glob(__dir__ + '/praroter/**/*.rb').sort.each do |path|
6
+ require path
7
+ end
8
+ end
@@ -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,15 @@
1
+ module Praroter
2
+ module NullLogger
3
+ def self.debug(*); end
4
+
5
+ def self.info(*); end
6
+
7
+ def self.warn(*); end
8
+
9
+ def self.error(*); end
10
+
11
+ def self.fatal(*); end
12
+
13
+ def self.unknown(*); end
14
+ end
15
+ end
@@ -0,0 +1,7 @@
1
+ module Praroter
2
+ class NullPool < Struct.new(:conn)
3
+ def with
4
+ yield conn
5
+ end
6
+ end
7
+ 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
@@ -0,0 +1,3 @@
1
+ module Praroter
2
+ VERSION = "1.0.0"
3
+ end
@@ -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
@@ -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
@@ -0,0 +1,2 @@
1
+ #!/bin/bash
2
+ redis-cli --ldb --eval lib/praroter/filly_bucket.lua filly_bucket.api.user:42.bucket_level filly_bucket.api.user:42.last_updated , 5000 100 1000
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: []