redis_token_bucket 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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: []