redis-throttler 0.1.0 → 0.1.2
Sign up to get free protection for your applications and to get access to all the features.
- 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'
|