redis_token_bucket 0.1.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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: e1991459cf61f197e4b366ade53dd41ecbaf26bd
4
+ data.tar.gz: 4931d16f9aa7fb622bf167fdbc74d4781bbdc548
5
+ SHA512:
6
+ metadata.gz: 3aa2fdf2aca0e7c374cd6845dece306ef0de74651f50b17328269d0abb5a38f9d742ae32ed9fb25119bd5922e166d00aceb42ce2574dae1da02df712d31e581a
7
+ data.tar.gz: 7f99aef70badd3708e784d240a81c1fa0f1c25a543f6d7fb66d40f7c5e7eb3d6cb4c7e64d2985aeb90aab834ea652ed8f940109fd4f39214e303d33e01de7ccc
@@ -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,12 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.2
4
+ before_install:
5
+ - gem update bundler
6
+ script: bundle exec rspec spec
7
+ services:
8
+ - redis-server
9
+ addons:
10
+ apt:
11
+ packages:
12
+ - redis-server
data/Gemfile ADDED
@@ -0,0 +1,7 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in redis_token_bucket.gemspec
4
+ gemspec
5
+
6
+ # needed by "demo.rb"
7
+ gem "concurrent-ruby"
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2016 Kristian Hanekamp
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,149 @@
1
+ # RedisTokenBucket
2
+
3
+ A [Token Bucket](https://en.wikipedia.org/wiki/Token_bucket) rate limiting implementation in Ruby using a Redis backend.
4
+
5
+ Features:
6
+ * Lightweight and efficient
7
+ * Uses a single Redis key per bucket
8
+ * Buckets are automatically created when first used
9
+ * Buckets are automatically removed when no longer used
10
+ * Fast and concurrency safe
11
+ * Each operation uses just a single network roundtrip to Redis
12
+ * Charging tokens is done with all-or-nothing semantics
13
+ * Computed continuously
14
+ * Token values (rate, size, current level, cost) use floating point numbers
15
+ * Bucket level is computed with microsecond precision
16
+ * Powerful and flexible
17
+ * Ability to charge multiple buckets with arbitrary token amounts at once
18
+ * Ability to "reserve" tokens and to create "token debt"
19
+
20
+ Redis version 3.2 or newer is needed.
21
+
22
+ ## Installation
23
+
24
+ Add this line to your application's Gemfile:
25
+
26
+ ```ruby
27
+ gem 'redis_token_bucket'
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ Basic rate limiting:
33
+
34
+ ```ruby
35
+ require 'redis'
36
+ require 'redis_token_bucket'
37
+
38
+ # create connection to redis server
39
+ # details see: https://github.com/redis/redis-rb/
40
+ redis = Redis.new
41
+
42
+ # create a limiter instance which uses the redis connection
43
+ limiter = RedisTokenBucket.limiter(redis)
44
+
45
+ # define the bucket
46
+ bucket = {
47
+ key: "RedisKeyForMyBucket",
48
+ rate: 100,
49
+ size: 1000,
50
+ }
51
+
52
+ # charge 10 tokens to the bucket
53
+ success, level = limiter.charge(bucket, 10)
54
+
55
+ # check if charging was successful
56
+ if success
57
+ # rate limiter permits request
58
+ call_my_business_logic
59
+ else
60
+ # rate limiter denies request
61
+ raise "Rate Limit exceeded. Increase you calm!"
62
+ end
63
+
64
+ # print the resulting level of tokens in the bucket
65
+ puts "The current level of tokens in my bucket: #{level}"
66
+
67
+ ```
68
+
69
+ Reading the current level of tokens of a bucket:
70
+
71
+ ```ruby
72
+ puts "Current level of tokens: #{limiter.read_level(bucket)}"
73
+ ```
74
+
75
+ Charging multiple buckets at once:
76
+
77
+ ```ruby
78
+ long_bucket = {
79
+ key: "RedisKeyForLongBucket",
80
+ rate: 100,
81
+ size: 10000
82
+ }
83
+
84
+ short_bucket = {
85
+ key: "RedisKeyForShortBucket",
86
+ rate: 1000,
87
+ size: 3000
88
+ }
89
+
90
+ success, levels = limiter.batch_charge(
91
+ [long_bucket, 1],
92
+ [short_bucket, 1]
93
+ )
94
+
95
+ puts "The current level of tokens in bucket short: #{levels[short_bucket[:key]]}"
96
+ puts "The current level of tokens in bucket long: #{levels[long_bucket[:key]]}"
97
+
98
+ if success
99
+ # rate limiter permits request (all buckets were charged)
100
+ call_my_business_logic
101
+ else
102
+ # rate limiter denies request (none of the buckets was charged)
103
+ raise "Rate Limit exceeded. Increase you calm!"
104
+ end
105
+ ```
106
+
107
+ Reading the current level of tokens from multiple buckets:
108
+
109
+ ```ruby
110
+ levels = limiter.read_levels(short_bucket, long_bucket)
111
+
112
+ puts "The current level of tokens in bucket short: #{levels[short_bucket[:key]]}"
113
+ puts "The current level of tokens in bucket long: #{levels[long_bucket[:key]]}"
114
+ ```
115
+
116
+ Advanced: Bucket with Reserved Tokens
117
+
118
+ ```ruby
119
+ # this reserves the last 10 tokens,
120
+ # i.e. charging will fail if it would result in less than 10 tokens
121
+
122
+ RedisTokenBucket.charge(bucket, 1, {limit: 10})
123
+
124
+ # also possible with batch_charge
125
+ RedisTokenBucket.batch_charge(
126
+ [short_bucket, 1, {limit: 10}],
127
+ [long_bucket, 2, {limit: 5}],
128
+ )
129
+ ```
130
+
131
+ Advanced: Bucket with Token Debt
132
+
133
+ ```ruby
134
+ # this allows up to 10 "negative" tokens
135
+ # i.e. charging will only fail if it would result in less than -10 tokens
136
+ RedisTokenBucket.charge(bucket, 1, {limit: -10})
137
+ ```
138
+
139
+ ## Development
140
+
141
+ After checking out the repo, run `bundle` to install dependencies.
142
+
143
+ Use `bundle exec rspec` to run tests.
144
+
145
+ Use `bundle exec ruby demo.rb` to run a demo.
146
+
147
+ ## Contributors
148
+
149
+ Original author: Kristian Hanekamp
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "redis_token_bucket"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start
@@ -0,0 +1,7 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+
5
+ bundle install
6
+
7
+ # Do any other automated setup that you need to do here
data/demo.rb ADDED
@@ -0,0 +1,109 @@
1
+ require "redis_token_bucket"
2
+ require "concurrent"
3
+ require "redis"
4
+ require "securerandom"
5
+
6
+ puts <<-EOS
7
+ The script attempts to continuously make requests against the rate limiter.
8
+ Each second, the number of requests accepted and denied by the rate limiter is printed.
9
+
10
+ You should see the following pattern:
11
+ * briefly, a burst of requests is accepted
12
+ * then the 'short' buckets starts limiting to 1000 requests per second
13
+ * after a few seconds, the 'long' buckets starts limiting to 100 requests per second
14
+
15
+ EOS
16
+
17
+ def random_key
18
+ "RedisTokenBucket:demo:#{SecureRandom.hex}"
19
+ end
20
+
21
+ buckets = {
22
+ long: {
23
+ key: random_key,
24
+ rate: 100,
25
+ size: 10000
26
+ },
27
+ short: {
28
+ key: random_key,
29
+ rate: 1000,
30
+ size: 3000
31
+ }
32
+ }
33
+
34
+ consumed = Concurrent::Atom.new(0)
35
+
36
+ rejected = {}
37
+ buckets.each { |name, _| rejected[name] = Concurrent::Atom.new(0) }
38
+
39
+ def increase(atom)
40
+ atom.swap { |before| before + 1 }
41
+ end
42
+
43
+ def reset(atom)
44
+ last = nil
45
+
46
+ atom.swap do |before|
47
+ last = before
48
+
49
+ 0
50
+ end
51
+
52
+ last
53
+ end
54
+
55
+ NUM_FORKS = 1
56
+ NUM_THREADS_PER_FORK = 10
57
+
58
+ child_processes = NUM_FORKS.times.map do
59
+ Process.fork do
60
+
61
+ # each fork has an independent output thread
62
+ output = Thread.new do
63
+ last_output = 0
64
+ while true
65
+ now = Time.now.to_i
66
+
67
+ if last_output < now
68
+ denied_stats = rejected.map { |name, atom| "#{name}: #{reset(atom)}" }
69
+ puts "Accepted: #{reset(consumed)} / Denied: #{denied_stats}"
70
+
71
+ last_output = now
72
+ end
73
+
74
+ sleep 0.001
75
+ end
76
+ end
77
+
78
+ # and a number of worker threads, charging tokens
79
+ workers = NUM_THREADS_PER_FORK.times.map do |i|
80
+ Thread.new do
81
+ begin
82
+ limiter = RedisTokenBucket.limiter(Redis.new)
83
+
84
+ while true
85
+ success, levels = limiter.batch_charge([buckets[:short], 1], [buckets[:long], 1])
86
+
87
+ if success
88
+ increase(consumed)
89
+ else
90
+ levels.map do |key, level|
91
+ name = buckets.keys.detect { |n| buckets[n][:key] == key }
92
+ increase(rejected[name]) if level < 1
93
+ end
94
+ end
95
+ end
96
+ rescue Exception => e
97
+ puts "ERROR"
98
+ puts e
99
+ puts e.backtrace
100
+ end
101
+ end
102
+ end
103
+
104
+ output.join
105
+ workers.join
106
+ end
107
+ end
108
+
109
+ child_processes.map { |pid| Process.waitpid(pid) }
@@ -0,0 +1,8 @@
1
+ require "redis_token_bucket/version"
2
+ require "redis_token_bucket/limiter"
3
+
4
+ module RedisTokenBucket
5
+ def self.limiter(redis)
6
+ Limiter.new(redis)
7
+ end
8
+ end
@@ -0,0 +1,73 @@
1
+ redis.replicate_commands()
2
+ local injected_time = tonumber(ARGV[1])
3
+ local redis_time = redis.call('time')
4
+ local local_time = redis_time[1] + 0.000001 * redis_time[2]
5
+
6
+ local now = injected_time or local_time
7
+
8
+ local current_bucket_levels = {}
9
+ local new_bucket_levels = {}
10
+ local timeouts = {}
11
+ local exceeded = false
12
+
13
+ for key_index, key in ipairs(KEYS) do
14
+ local arg_index = key_index * 4 - 2
15
+ local rate = tonumber(ARGV[arg_index])
16
+ local size = tonumber(ARGV[arg_index + 1])
17
+ local amount = tonumber(ARGV[arg_index + 2])
18
+
19
+ local bucket = redis.call('hmget', key, 'time', 'level')
20
+ local last_time = tonumber(bucket[1]) or now
21
+ local before_level = tonumber(bucket[2]) or size
22
+
23
+ local elapsed = math.max(0, now - last_time)
24
+ local gained = rate * elapsed
25
+
26
+ local current_level = math.min(size, before_level + gained)
27
+
28
+ current_bucket_levels[key_index] = current_level
29
+
30
+ if amount > 0 then
31
+ local limit = tonumber(ARGV[arg_index + 3]) or 0
32
+
33
+ local new_level = current_level - amount
34
+ new_bucket_levels[key_index] = new_level
35
+
36
+ local seconds_to_full = (size - new_level) / rate
37
+ timeouts[key_index] = seconds_to_full
38
+
39
+ if new_level < limit then
40
+ exceeded = true
41
+ end
42
+ end
43
+ end
44
+
45
+ local levels_to_report
46
+ local charged
47
+
48
+ if exceeded or #new_bucket_levels == 0 then
49
+ levels_to_report = current_bucket_levels
50
+ charged = 0
51
+ else
52
+ levels_to_report = new_bucket_levels
53
+ charged = 1
54
+
55
+ for key_index, key in ipairs(KEYS) do
56
+ local new_level = new_bucket_levels[key_index]
57
+ local timeout = timeouts[key_index]
58
+
59
+ redis.call('hmset', key,
60
+ 'time', string.format("%.16g", now),
61
+ 'level', string.format("%.16g", new_level)
62
+ )
63
+
64
+ redis.call('expire', key, math.ceil(timeout))
65
+ end
66
+ end
67
+
68
+ local formatted_levels = {}
69
+ for index, value in ipairs(levels_to_report) do
70
+ formatted_levels[index] = string.format("%.16g", value)
71
+ end
72
+
73
+ return {charged, formatted_levels}
@@ -0,0 +1,106 @@
1
+ module RedisTokenBucket
2
+ class Limiter
3
+ def initialize(redis, clock = nil)
4
+ @redis = redis
5
+ @clock = clock
6
+ end
7
+
8
+ # charges `amount` tokens to the specified `bucket`.
9
+ #
10
+ # charging only happens if the bucket has sufficient tokens.
11
+ # the level of "sufficient tokens" can be adjusted by passing in option[:limit]
12
+ #
13
+ # returns a tuple (= Array with two elements) containing
14
+ # `success:boolean` and `level:Numeric`
15
+ def charge(bucket, amount, options = nil)
16
+ success, levels = batch_charge([bucket, amount, options])
17
+
18
+ return success, levels[bucket[:key]]
19
+ end
20
+
21
+ # performs several bucket charge operations in batch.
22
+ #
23
+ # each operation is passed in as an Array, containing the parameters
24
+ # for `batch`.
25
+ #
26
+ # charging only happens if all buckets have sufficient tokens.
27
+ # the charges are done transactionally, so either all buckets are charged or none.
28
+ #
29
+ # returns a tuple (= Array with two elements) containing
30
+ # `success:boolean` and `levels:Hash<String, Numeric>`
31
+ # where `levels` is a hash from bucket keys to bucket levels.
32
+ def batch_charge(*charges)
33
+ charges.each do |(bucket, amount, options)|
34
+ unless amount > 0
35
+ message = "tried to charge #{amount}, needs to be Numeric and > 0"
36
+ raise ArgumentError, message
37
+ end
38
+ end
39
+
40
+ run_script(charges)
41
+ end
42
+
43
+ # returns the current level of tokens in the specified `bucket`.
44
+ def read_level(bucket)
45
+ read_levels(bucket)[bucket[:key]]
46
+ end
47
+
48
+ # reports the current level of tokens for each of the specified `buckets`.
49
+ # returns the levels as a Hash from bucket keys to bucket levels.
50
+ def read_levels(*buckets)
51
+ _, levels = run_script(buckets.map { |bucket| [bucket, 0] })
52
+
53
+ levels
54
+ end
55
+
56
+ private
57
+
58
+ def run_script(charges)
59
+ props = charges.map(&method(:props_for_charge)).flatten
60
+ time = @clock.call if @clock
61
+
62
+ argv = [time] + props
63
+ keys = charges.map { |(bucket, _, _)| bucket[:key] }
64
+
65
+ success, levels = eval_script(:keys => keys, :argv => argv)
66
+
67
+ levels_as_hash = {}
68
+ levels.each_with_index do |level, index|
69
+ levels_as_hash[keys[index]] = level.to_f
70
+ end
71
+
72
+ [success > 0, levels_as_hash]
73
+ end
74
+
75
+ def props_for_charge(charge)
76
+ bucket, amount, options = charge
77
+
78
+ [bucket[:rate], bucket[:size], amount, options ? options[:limit] : nil]
79
+ end
80
+
81
+ def eval_script(options)
82
+ retries = 0
83
+
84
+ begin
85
+ @redis.evalsha(script_sha, options)
86
+ rescue Redis::CommandError => e
87
+ if retries > 0
88
+ raise
89
+ end
90
+
91
+ @@script_sha = nil
92
+
93
+ retries = 1
94
+ retry
95
+ end
96
+ end
97
+
98
+ def script_sha
99
+ @@script_sha ||= @redis.script(:load, script_code)
100
+ end
101
+
102
+ def script_code
103
+ @@script ||= File.read(File.expand_path("../limiter.lua", __FILE__))
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,3 @@
1
+ module RedisTokenBucket
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,26 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'redis_token_bucket/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "redis_token_bucket"
8
+ spec.version = RedisTokenBucket::VERSION
9
+ spec.authors = ["Kristian Hanekamp"]
10
+ spec.email = ["kris.hanekamp@gmail.com"]
11
+
12
+ spec.summary = %q{Token Bucket Rate Limiting using Redis}
13
+ spec.homepage = "https://github.com/krishan/redis_token_bucket"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
17
+ spec.bindir = "exe"
18
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency "redis", "~> 3.0"
22
+
23
+ spec.add_development_dependency "bundler", "~> 1.9"
24
+ spec.add_development_dependency "rake", "~> 10.0"
25
+ spec.add_development_dependency "rspec", "~> 3.5"
26
+ end
metadata ADDED
@@ -0,0 +1,115 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: redis_token_bucket
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kristian Hanekamp
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2016-12-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: redis
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.9'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.9'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '10.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '10.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.5'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.5'
69
+ description:
70
+ email:
71
+ - kris.hanekamp@gmail.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - ".gitignore"
77
+ - ".rspec"
78
+ - ".travis.yml"
79
+ - Gemfile
80
+ - LICENSE.txt
81
+ - README.md
82
+ - Rakefile
83
+ - bin/console
84
+ - bin/setup
85
+ - demo.rb
86
+ - lib/redis_token_bucket.rb
87
+ - lib/redis_token_bucket/limiter.lua
88
+ - lib/redis_token_bucket/limiter.rb
89
+ - lib/redis_token_bucket/version.rb
90
+ - redis_token_bucket.gemspec
91
+ homepage: https://github.com/krishan/redis_token_bucket
92
+ licenses:
93
+ - MIT
94
+ metadata: {}
95
+ post_install_message:
96
+ rdoc_options: []
97
+ require_paths:
98
+ - lib
99
+ required_ruby_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ required_rubygems_version: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: '0'
109
+ requirements: []
110
+ rubyforge_project:
111
+ rubygems_version: 2.2.2
112
+ signing_key:
113
+ specification_version: 4
114
+ summary: Token Bucket Rate Limiting using Redis
115
+ test_files: []