redis-throttler 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.gitignore +1 -1
- data/gemspec.yml +3 -0
- data/lib/redis-throttler/model.rb +55 -0
- data/lib/redis-throttler/version.rb +3 -0
- data/lib/redis-throttler.rb +123 -0
- data/redis-throttler.gemspec +2 -2
- data/spec/spec_helper.rb +1 -2
- data/spec/throttler_spec.rb +98 -4
- metadata +19 -4
- data/lib/redis/throttler/version.rb +0 -6
- data/lib/redis/throttler.rb +0 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2b589098c335605b23f3d2db1102accd163b8ccf
|
4
|
+
data.tar.gz: ad9e138f8a639c34a546e7f1dcba365c0b7b55d8
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 397f06f7b92833db7a96b6ffc9933aa72f4332a73bc68797a2dfd4d89f0d0a2df265b0216e4cfe690ce1f30814fe96418ae03b1771d627e36a417f453cf9637d
|
7
|
+
data.tar.gz: 737c45f3a4d19bfd171ffcfbc798e7b7dedf1f78c458a1b7db573ecd2ba60d82c812fee031665e210262a52766cb20b960a3d8ffddcf52a7c457f1e3cbfac723
|
data/.gitignore
CHANGED
data/gemspec.yml
CHANGED
@@ -0,0 +1,55 @@
|
|
1
|
+
require 'redis-throttler'
|
2
|
+
class RedisThrottler
|
3
|
+
module Model
|
4
|
+
|
5
|
+
def self.included(base)
|
6
|
+
base.extend(ClassMethods)
|
7
|
+
base.instance_eval { @limits ||= {} }
|
8
|
+
end
|
9
|
+
|
10
|
+
module ClassMethods
|
11
|
+
# @param [Symbol] key
|
12
|
+
# @param [Hash] opts
|
13
|
+
def throttle(key, opts = {})
|
14
|
+
klass = self.to_s.downcase
|
15
|
+
key = "#{key.to_s}"
|
16
|
+
|
17
|
+
subject = opts[:by] || :id
|
18
|
+
limit = opts[:limit] || 5
|
19
|
+
threshold = opts[:for] || 900
|
20
|
+
interval = opts[:interval] || 5
|
21
|
+
|
22
|
+
limiter = RedisThrottler.new("#{klass}:#{key}", bucket_interval: interval, bucket_span: threshold)
|
23
|
+
@limits[key] = "#{subject.to_s} limit #{limit} per #{threshold} sec"
|
24
|
+
|
25
|
+
# includes('?') will return true
|
26
|
+
method = "#{key}_limiter"
|
27
|
+
|
28
|
+
%w(limits limits?).each do |string|
|
29
|
+
define_singleton_method(string) { string.include?('?') || @limits }
|
30
|
+
define_method(string) { string.include?('?') || @limits }
|
31
|
+
end
|
32
|
+
|
33
|
+
# i used Procs because they don't complain about arity
|
34
|
+
# these Procs will return a string to be evaluated in context
|
35
|
+
|
36
|
+
methods = {
|
37
|
+
:exceeded? => proc { |to_call| "#{method}.exceeded? \"#{to_call}\", threshold: #{limit}, interval: #{threshold}" },
|
38
|
+
:increment => proc { |to_call| "#{method}.add(\"#{to_call}\")" },
|
39
|
+
:count => proc { |to_call, within| "#{method}.count(\"#{to_call}\", #{within})" }
|
40
|
+
}
|
41
|
+
|
42
|
+
# define the class & instance methods
|
43
|
+
# pass the id to access counters
|
44
|
+
define_singleton_method(method) { limiter }
|
45
|
+
define_method(method) { self.class.send method }
|
46
|
+
|
47
|
+
methods.each do |magic, meth|
|
48
|
+
define_singleton_method("#{key}_#{magic.to_s}") { |id, within = threshold| eval meth.call(id, within) }
|
49
|
+
define_method("#{key}_#{magic.to_s}") { |within = threshold| eval meth.call("#{self.send subject}", within) }
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,123 @@
|
|
1
|
+
require 'redis'
|
2
|
+
|
3
|
+
class RedisThrottler
|
4
|
+
def self.included(base)
|
5
|
+
base.extend(RedisThrottler::Model)
|
6
|
+
end
|
7
|
+
# Create a RedisThrottler object.
|
8
|
+
#
|
9
|
+
# @param [String] key A name to uniquely identify this rate limit. For example, 'emails'
|
10
|
+
# @param [Hash] options Options hash
|
11
|
+
# @option options [Integer] :bucket_span (600) Time span to track in seconds
|
12
|
+
# @option options [Integer] :bucket_interval (5) How many seconds each bucket represents
|
13
|
+
# @option options [Integer] :bucket_expiry (@bucket_span) How long we keep data in each bucket before it is auto expired. Cannot be larger than the bucket_span.
|
14
|
+
# @option options [Redis] :redis (nil) Redis client if you need to customize connection options
|
15
|
+
#
|
16
|
+
# @return [RedisThrottler] RedisThrottler instance
|
17
|
+
#
|
18
|
+
def initialize(key, options = {})
|
19
|
+
@key = key
|
20
|
+
@bucket_span = options[:bucket_span] || 600
|
21
|
+
@bucket_interval = options[:bucket_interval] || 5
|
22
|
+
@bucket_expiry = options[:bucket_expiry] || @bucket_span
|
23
|
+
if @bucket_expiry > @bucket_span
|
24
|
+
raise ArgumentError.new("Bucket expiry cannot be larger than the bucket span")
|
25
|
+
end
|
26
|
+
@bucket_count = (@bucket_span / @bucket_interval).round
|
27
|
+
if @bucket_count < 3
|
28
|
+
raise ArgumentError.new("Cannot have less than 3 buckets")
|
29
|
+
end
|
30
|
+
@redis = options[:redis]
|
31
|
+
end
|
32
|
+
|
33
|
+
# Increment counter for a given subject.
|
34
|
+
#
|
35
|
+
# @param [String] subject A unique key to identify the subject. For example, 'user@foo.com'
|
36
|
+
# @param [Integer] count The number by which to increment the counter
|
37
|
+
#
|
38
|
+
# @return [Integer] increments within interval
|
39
|
+
def add(subject, count = 1)
|
40
|
+
bucket = get_bucket
|
41
|
+
subject = "#{@key}:#{subject}"
|
42
|
+
redis.pipelined do
|
43
|
+
redis.hincrby(subject, bucket, count)
|
44
|
+
redis.hdel(subject, (bucket + 1) % @bucket_count)
|
45
|
+
redis.hdel(subject, (bucket + 2) % @bucket_count)
|
46
|
+
redis.expire(subject, @bucket_expiry)
|
47
|
+
end.first
|
48
|
+
end
|
49
|
+
|
50
|
+
# Returns the count for a given subject and interval
|
51
|
+
#
|
52
|
+
# @param [String] subject Subject for the count
|
53
|
+
# @param [Integer] interval How far back (in seconds) to retrieve activity.
|
54
|
+
#
|
55
|
+
# @return [Integer] current count for subject
|
56
|
+
def count(subject, interval)
|
57
|
+
bucket = get_bucket
|
58
|
+
interval = [interval, @bucket_interval].max
|
59
|
+
count = (interval / @bucket_interval).floor
|
60
|
+
subject = "#{@key}:#{subject}"
|
61
|
+
|
62
|
+
keys = (0..count - 1).map do |i|
|
63
|
+
(bucket - i) % @bucket_count
|
64
|
+
end
|
65
|
+
redis.hmget(subject, *keys).inject(0) {|a, i| a + i.to_i}
|
66
|
+
end
|
67
|
+
|
68
|
+
# Check if the rate limit has been exceeded.
|
69
|
+
#
|
70
|
+
# @param [String] subject Subject to check
|
71
|
+
# @param [Hash] options Options hash
|
72
|
+
# @option options [Integer] :interval How far back to retrieve activity.
|
73
|
+
# @option options [Integer] :threshold Maximum number of actions
|
74
|
+
#
|
75
|
+
# @return [Boolean] true if exceeded
|
76
|
+
def exceeded?(subject, options = {})
|
77
|
+
count(subject, options[:interval]) >= options[:threshold]
|
78
|
+
end
|
79
|
+
|
80
|
+
# Check if the rate limit is within bounds
|
81
|
+
#
|
82
|
+
# @param [String] subject Subject to check
|
83
|
+
# @param [Hash] options Options hash
|
84
|
+
# @option options [Integer] :interval How far back to retrieve activity.
|
85
|
+
# @option options [Integer] :threshold Maximum number of actions
|
86
|
+
#
|
87
|
+
# @return [Integer] true if within bounds
|
88
|
+
def within_bounds?(subject, options = {})
|
89
|
+
!exceeded?(subject, options)
|
90
|
+
end
|
91
|
+
|
92
|
+
# Execute a block once the rate limit is within bounds
|
93
|
+
# *WARNING* This will block the current thread until the rate limit is within bounds.
|
94
|
+
#
|
95
|
+
# @param [String] subject Subject for this rate limit
|
96
|
+
# @param [Hash] options Options hash
|
97
|
+
# @option options [Integer] :interval How far back to retrieve activity.
|
98
|
+
# @option options [Integer] :threshold Maximum number of actions
|
99
|
+
# @yield The block to be run
|
100
|
+
#
|
101
|
+
# @example Send an email as long as we haven't send 5 in the last 10 minutes
|
102
|
+
# RedisThrottler.exec_with_threshold(email, [:threshold => 5, :interval => 600]) do
|
103
|
+
# send_another_email
|
104
|
+
# end
|
105
|
+
def exec_within_threshold(subject, options = {}, &block)
|
106
|
+
options[:threshold] ||= 30
|
107
|
+
options[:interval] ||= 30
|
108
|
+
while exceeded?(subject, options)
|
109
|
+
sleep @bucket_interval
|
110
|
+
end
|
111
|
+
yield(self)
|
112
|
+
end
|
113
|
+
|
114
|
+
private
|
115
|
+
|
116
|
+
def get_bucket(time = Time.now.to_i)
|
117
|
+
((time % @bucket_span) / @bucket_interval).floor
|
118
|
+
end
|
119
|
+
|
120
|
+
def redis
|
121
|
+
@redis ||= Redis.new(host: '192.168.99.100', port: 32771)
|
122
|
+
end
|
123
|
+
end
|
data/redis-throttler.gemspec
CHANGED
@@ -10,8 +10,8 @@ Gem::Specification.new do |gem|
|
|
10
10
|
lib_dir = File.join(File.dirname(__FILE__),'lib')
|
11
11
|
$LOAD_PATH << lib_dir unless $LOAD_PATH.include?(lib_dir)
|
12
12
|
|
13
|
-
require 'redis
|
14
|
-
|
13
|
+
require 'redis-throttler/version'
|
14
|
+
RedisThrottler::VERSION
|
15
15
|
end
|
16
16
|
|
17
17
|
gem.summary = gemspec['summary']
|
data/spec/spec_helper.rb
CHANGED
data/spec/throttler_spec.rb
CHANGED
@@ -1,8 +1,102 @@
|
|
1
1
|
require 'spec_helper'
|
2
|
-
require 'redis/throttler'
|
3
2
|
|
4
|
-
describe
|
5
|
-
|
6
|
-
|
3
|
+
describe RedisThrottler do
|
4
|
+
|
5
|
+
before do
|
6
|
+
@rl = RedisThrottler.new('test')
|
7
|
+
@rl.send(:redis).flushdb
|
8
|
+
end
|
9
|
+
|
10
|
+
it 'should set_bucket_expiry to the bucket_span if not defined' do
|
11
|
+
expect(@rl.instance_variable_get(:@bucket_span)).to eq(@rl.instance_variable_get(:@bucket_expiry))
|
12
|
+
end
|
13
|
+
|
14
|
+
it 'should not allow bucket count less than 3' do
|
15
|
+
expect do
|
16
|
+
RedisThrottler.new('test', {:bucket_span => 1, :bucket_interval => 1})
|
17
|
+
end.to raise_error(ArgumentError)
|
18
|
+
end
|
19
|
+
|
20
|
+
it 'should not allow bucket expiry to be larger than the bucket span' do
|
21
|
+
expect do
|
22
|
+
RedisThrottler.new("key", {:bucket_expiry => 1200})
|
23
|
+
end.to raise_error(ArgumentError)
|
24
|
+
end
|
25
|
+
|
26
|
+
it 'should be able to add to the count for a given subject' do
|
27
|
+
@rl.add("value1")
|
28
|
+
@rl.add("value1")
|
29
|
+
expect(@rl.count('value1', 1)).to eq(2)
|
30
|
+
expect(@rl.count("value2", 1)).to eq(0)
|
31
|
+
# Timecop.travel(600) do
|
32
|
+
# expect(@rl.count("value1", 1)).to eq(0)
|
33
|
+
# end
|
34
|
+
end
|
35
|
+
|
36
|
+
it 'should be able to add to the count by more than 1' do
|
37
|
+
@rl.add("value1", 3)
|
38
|
+
expect(@rl.count("value1", 1)).to eq(3)
|
39
|
+
end
|
40
|
+
|
41
|
+
it 'should be able to add to the count for a non-string subject' do
|
42
|
+
@rl.add(123)
|
43
|
+
@rl.add(123)
|
44
|
+
expect(@rl.count(123, 1)).to eq(2)
|
45
|
+
expect(@rl.count(124, 1)).to eq(0)
|
46
|
+
# Timecop.travel(10) do
|
47
|
+
# expect(@rl.count(123, 1)).to eq(0)
|
48
|
+
# end
|
49
|
+
end
|
50
|
+
|
51
|
+
it 'should return counter value' do
|
52
|
+
counter_value = @rl.add("value1")
|
53
|
+
expect(@rl.count("value1", 1)).to eq(counter_value)
|
54
|
+
end
|
55
|
+
|
56
|
+
it 'respond to exceeded? method correctly' do
|
57
|
+
5.times do
|
58
|
+
@rl.add("value1")
|
59
|
+
end
|
60
|
+
|
61
|
+
expect(@rl.exceeded?("value1", {:threshold => 10, :interval => 30})).to be false
|
62
|
+
expect(@rl.within_bounds?("value1", {:threshold => 10, :interval => 30})).to be true
|
63
|
+
|
64
|
+
10.times do
|
65
|
+
@rl.add("value1")
|
66
|
+
end
|
67
|
+
|
68
|
+
expect(@rl.exceeded?("value1", {:threshold => 10, :interval => 30})).to be true
|
69
|
+
expect(@rl.within_bounds?("value1", {:threshold => 10, :interval => 30})).to be false
|
70
|
+
end
|
71
|
+
|
72
|
+
# it "accept a threshold and a block that gets executed once it's below the threshold" do
|
73
|
+
# expect(@rl.count("key", 30)).to eq(0)
|
74
|
+
# 31.times do
|
75
|
+
# @rl.add("key")
|
76
|
+
# end
|
77
|
+
# expect(@rl.count("key", 30)).to eq(31)
|
78
|
+
#
|
79
|
+
# @value = nil
|
80
|
+
# expect do
|
81
|
+
# timeout(1) do
|
82
|
+
# @rl.exec_within_threshold("key", {:threshold => 30, :interval => 30}) do
|
83
|
+
# @value = 2
|
84
|
+
# end
|
85
|
+
# end
|
86
|
+
# end.to raise_error(Timeout::Error)
|
87
|
+
# expect(@value).to be nil
|
88
|
+
# # Timecop.travel(40) do
|
89
|
+
# # @rl.exec_within_threshold("key", {:threshold => 30, :interval => 30}) do
|
90
|
+
# # @value = 1
|
91
|
+
# # end
|
92
|
+
# # end
|
93
|
+
# # expect(@value).to be 1
|
94
|
+
# end
|
95
|
+
|
96
|
+
it 'counts correclty if bucket_span equals count-interval ' do
|
97
|
+
@rl = RedisThrottler.new('key', {:bucket_span => 10, bucket_interval: 1})
|
98
|
+
@rl.add('value1')
|
99
|
+
expect(@rl.count('value1', 10)).to eql(1)
|
7
100
|
end
|
101
|
+
|
8
102
|
end
|
metadata
CHANGED
@@ -1,15 +1,29 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: redis-throttler
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Evan Surdam
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2016-
|
11
|
+
date: 2016-08-01 00:00:00.000000000 Z
|
12
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'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '3'
|
13
27
|
- !ruby/object:Gem::Dependency
|
14
28
|
name: bundler
|
15
29
|
requirement: !ruby/object:Gem::Requirement
|
@@ -99,8 +113,9 @@ files:
|
|
99
113
|
- README.md
|
100
114
|
- Rakefile
|
101
115
|
- gemspec.yml
|
102
|
-
- lib/redis
|
103
|
-
- lib/redis
|
116
|
+
- lib/redis-throttler.rb
|
117
|
+
- lib/redis-throttler/model.rb
|
118
|
+
- lib/redis-throttler/version.rb
|
104
119
|
- redis-throttler.gemspec
|
105
120
|
- spec/spec_helper.rb
|
106
121
|
- spec/throttler_spec.rb
|
data/lib/redis/throttler.rb
DELETED
@@ -1 +0,0 @@
|
|
1
|
-
require 'redis/throttler/version'
|