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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 8867c14afca284cff1a0120b80bd260a75e20457
4
- data.tar.gz: ebd9aeef2e2aea7e045d22799cd832d5f6a80149
3
+ metadata.gz: 2b589098c335605b23f3d2db1102accd163b8ccf
4
+ data.tar.gz: ad9e138f8a639c34a546e7f1dcba365c0b7b55d8
5
5
  SHA512:
6
- metadata.gz: 378dd004616c326d80ae8534fccb0f59d4075faf3ca0b6c950fe259f115b73dddaf4aac2fe2b6d3f8a41de388a5dd6b0efff1cfe423ced94da663566029809ab
7
- data.tar.gz: 94916b706112edc47d883eea54c93fc7f1f665809ff60ac41b773a92a996b9e498325e4f85a5e645621f5a2384561c0d46f40fb4547204baffefb637817e91c6
6
+ metadata.gz: 397f06f7b92833db7a96b6ffc9933aa72f4332a73bc68797a2dfd4d89f0d0a2df265b0216e4cfe690ce1f30814fe96418ae03b1771d627e36a417f453cf9637d
7
+ data.tar.gz: 737c45f3a4d19bfd171ffcfbc798e7b7dedf1f78c458a1b7db573ecd2ba60d82c812fee031665e210262a52766cb20b960a3d8ffddcf52a7c457f1e3cbfac723
data/.gitignore CHANGED
@@ -1,4 +1,4 @@
1
- /.ideo
1
+ /.idea
2
2
  *DS_Store
3
3
  /.ruby-*
4
4
  /.bundle
data/gemspec.yml CHANGED
@@ -6,6 +6,9 @@ authors: Evan Surdam
6
6
  email: es@cosi.io
7
7
  homepage: https://github.com/esurdam/redis-throttler#readme
8
8
 
9
+ dependencies:
10
+ redis: ~> 3
11
+
9
12
  development_dependencies:
10
13
  bundler: ~> 1.10
11
14
  rake: ~> 10.0
@@ -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,3 @@
1
+ class RedisThrottler
2
+ VERSION = '0.1.2'
3
+ 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
@@ -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/throttler/version'
14
- Redis::Throttler::VERSION
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
@@ -1,4 +1,3 @@
1
1
  require 'rspec'
2
- require 'redis/throttler/version'
2
+ require 'redis-throttler'
3
3
 
4
- include Redis::Throttler
@@ -1,8 +1,102 @@
1
1
  require 'spec_helper'
2
- require 'redis/throttler'
3
2
 
4
- describe Redis::Throttler do
5
- it "should have a VERSION constant" do
6
- expect(subject.const_get('VERSION')).to_not be_empty
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.0
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-07-31 00:00:00.000000000 Z
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/throttler.rb
103
- - lib/redis/throttler/version.rb
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
@@ -1,6 +0,0 @@
1
- module Redis
2
- module Throttler
3
- # redis-throttler version
4
- VERSION = "0.1.0"
5
- end
6
- end
@@ -1 +0,0 @@
1
- require 'redis/throttler/version'