prop 0.4.1 → 0.5.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (7) hide show
  1. data/.gemtest +0 -0
  2. data/README.rdoc +29 -30
  3. data/VERSION +1 -1
  4. data/lib/prop.rb +39 -46
  5. data/prop.gemspec +3 -2
  6. data/test/test_prop.rb +47 -89
  7. metadata +5 -4
data/.gemtest ADDED
File without changes
data/README.rdoc CHANGED
@@ -17,60 +17,59 @@ You can choose to rely on a database or Moneta or Redis or whatever you'd like t
17
17
 
18
18
  Once the read and write operations are defined, you can optionally define some preconfigured default thresholds. If for example, you want to have a threshold on accepted emails per hour from a given user, you could define a threshold and interval (in seconds) for this like so:
19
19
 
20
- Prop.setup(:mails_per_hour, :threshold => 100, :interval => 1.hour)
20
+ Prop.defaults(:mails_per_hour, :threshold => 100, :interval => 1.hour)
21
21
 
22
- This results in a the following methods being generated:
22
+ You can now put the throttle to work with this values, by passing the "handle" to the respective methods in Prop:
23
23
 
24
24
  # Throws Prop::RateLimitExceededError if the threshold/interval has been reached
25
- Prop.throttle_mails_per_hour!
25
+ Prop.throttle!(:mails_per_hour)
26
26
 
27
27
  # Returns true if the threshold/interval has been reached
28
- Prop.throttle_mails_per_hour?
28
+ Prop.throttled?(:mails_per_hour)
29
29
 
30
- # Sets the counter to 0
31
- Prop.reset_mails_per_hour
30
+ # Sets the throttle "count" to 0
31
+ Prop.reset(:mails_per_hour)
32
+
33
+ # Returns the value of this throttle, usually a count, but see below for more
34
+ Prop.query(:mails_per_hour)
32
35
 
33
36
  In many cases you will want to tie a specific key to a defined throttle, for example you can scope the throttling to a specific sender rather than running a global "mails per hour" throttle:
34
37
 
35
- Prop.throttle_mails_per_hour!(mail.from)
38
+ Prop.throttle!(:mails_per_hour, mail.from)
39
+ Prop.throttled?(:mails_per_hour, mail.from)
40
+ Prop.reset(:mails_per_hour, mail.from)
41
+ Prop.query(:mails_per_hour, mail.from)
42
+
43
+ The throttle scope can also be an array of values, e.g.:
36
44
 
37
- If this method gets called more than "threshold" times within "interval in seconds" Prop throws a Prop::RateLimitExceededError. You can change the message of this error, which is handy when you are using Prop in multiple locations and want to be able to differentiate further up the stack. For example:
45
+ Prop.throttle!(:mails_per_hour, [ account.id, mail.from ])
38
46
 
39
- Prop.setup(:logins, :threshold => 5, :interval => 5.minutes, :message => "Too many invalid login attempts.")
47
+ If the throttle! method gets called more than "threshold" times within "interval in seconds" for a given handle and key combination, Prop throws a Prop::RateLimitExceededError. This exception contains a "handle" reference, which is handy when you are using Prop in multiple locations and want to be able to differentiate further up the stack. For example, in Rails you can use this in e.g. ApplicationController:
40
48
 
41
- In Rails you can use this in e.g. ApplicationController:
49
+ THROTTLE_MESSAGES = Hash.new("Throttle exceeded")
50
+ THROTTLE_MESSAGES[:login] = "Too many invalid login attempts. Try again later."
42
51
 
43
52
  rescue_from Prop::RateLimitExceededError do |exception|
44
- render :status => 403, :message => exception.message
53
+ render :status => 403, :message => THROTTLE_MESSAGES[exception.handle]
45
54
  end
46
55
 
47
56
  You can chose to override the threshold for a given key:
48
57
 
49
- Prop.throttle_mails_per_hour!(mail.from, :threshold => account.mail_throttle_threshold)
50
-
51
- If you wish to reset a specific throttle, you can do that like so:
52
-
53
- Prop.reset_mails_per_hour(mail.from)
54
-
55
- When the threshold are invoked without argument, the key is nil and as such a scope of its own.
56
-
57
- The default (and smallest possible) increment is 1, you can of course set that to any integer value using :increment
58
+ Prop.throttle!(:mails_per_hour, mail.from, :threshold => account.mail_throttle_threshold)
58
59
 
59
- Prop.setup(:execution_time_ms, :threshold => 1000, :interval => 1.minute)
60
- Prop.throttle_execution_time_ms!(:increment => (Benchmark.realtime { execute } * 1000).to_i)
60
+ When the threshold are invoked without argument, the key is nil and as such a scope of its own, i.e. these are equivalent:
61
61
 
62
- You can gauge the current value of a throttle using count:
62
+ Prop.throttle!(:mails_per_hour)
63
+ Prop.throttle!(:mails_per_hour, nil)
63
64
 
64
- Prop.count_mails_per_hour!(mail.from)
65
+ The default (and smallest possible) increment is 1, you can set that to any integer value using :increment which is handy for building time based throttles:
65
66
 
66
- Lastly you can use Prop without registering the thresholds up front:
67
+ Prop.setup(:execute_time, :threshold => 10, :interval => 1.minute)
68
+ Prop.throttle!(:execute_time, account.id, :increment => (Benchmark.realtime { execute }).to_i)
67
69
 
68
- Prop.throttle!(:key => 'nuisance@example.com', :threshold => 100, :interval -> 1.hour)
69
- Prop.throttle?(:key => 'nuisance@example.com', :threshold => 100, :interval -> 1.hour)
70
- Prop.reset(:key => 'nuisance@example.com', :threshold => 100, :interval -> 1.hour)
71
- Prop.count(:key => 'nuisance@example.com', :threshold => 100, :interval -> 1.hour)
70
+ == How it works
72
71
 
73
- It's up to you to pass an appropriate key which reflects the scope you're rate limiting. The interval is tied to the underlying key generating mechanism, so if you change that between calls and have all other things equal, then that will result in different throttles being set.
72
+ Prop uses the interval to define a window of time using simple div arithmetic. This means that it's a worst case throttle that will allow up to 2 times the specified requests within the specified interval.
74
73
 
75
74
  == Note on Patches/Pull Requests
76
75
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.4.1
1
+ 0.5.1
data/lib/prop.rb CHANGED
@@ -8,14 +8,12 @@ end
8
8
 
9
9
  class Prop
10
10
  class RateLimitExceededError < RuntimeError
11
- attr_accessor :root_message, :retry_after
12
-
13
- def self.create(key, threshold, message)
14
- default = "#{key} threshold #{threshold} exceeded"
15
- error = new(message || default)
16
- error.retry_after = threshold - Time.now.to_i % threshold if threshold > 0
17
- error.root_message = default
11
+ attr_accessor :handle, :retry_after
18
12
 
13
+ def self.create(handle, key, threshold)
14
+ error = new("#{handle} threshold of #{threshold} exceeded for key '#{key}'")
15
+ error.handle = handle
16
+ error.retry_after = threshold - Time.now.to_i % threshold if threshold > 0
19
17
  raise error
20
18
  end
21
19
  end
@@ -31,69 +29,64 @@ class Prop
31
29
  self.writer = blk
32
30
  end
33
31
 
34
- def setup(handle, defaults)
32
+ def defaults(handle, defaults)
35
33
  raise RuntimeError.new("Invalid threshold setting") unless defaults[:threshold].to_i > 0
36
- raise RuntimeError.new("Invalid interval setting") unless defaults[:interval].to_i > 0
37
-
38
- define_prop_class_method "throttle_#{handle}!" do |*args|
39
- throttle!(sanitized_prop_options([ handle ] + args, defaults))
40
- end
41
-
42
- define_prop_class_method "throttle_#{handle}?" do |*args|
43
- throttle?(sanitized_prop_options([ handle ] + args, defaults))
44
- end
45
-
46
- define_prop_class_method "reset_#{handle}" do |*args|
47
- reset(sanitized_prop_options([ handle ] + args, defaults))
48
- end
34
+ raise RuntimeError.new("Invalid interval setting") unless defaults[:interval].to_i > 0
49
35
 
50
- define_prop_class_method "count_#{handle}" do |*args|
51
- count(sanitized_prop_options([ handle ] + args, defaults))
52
- end
36
+ self.handles ||= {}
37
+ self.handles[handle] = defaults
53
38
  end
54
39
 
55
- def throttle?(options)
56
- count(options) >= options[:threshold]
57
- end
58
-
59
- def throttle!(options)
60
- counter = count(options)
40
+ def throttle!(handle, key = nil, options = {})
41
+ options = sanitized_prop_options(handle, key, options)
42
+ cache_key = sanitized_prop_key(key, options[:interval])
43
+ counter = reader.call(cache_key).to_i
61
44
 
62
45
  if counter >= options[:threshold]
63
- if options[:progressive]
64
- writer.call(sanitized_prop_key(options.merge(:window_modifier => 1)), counter)
65
- end
66
- raise Prop::RateLimitExceededError.create(options[:key], options[:threshold], options[:message])
46
+ raise Prop::RateLimitExceededError.create(handle, normalize_cache_key(key), options[:threshold])
67
47
  else
68
- writer.call(sanitized_prop_key(options), counter + [ 1, options[:increment].to_i ].max)
48
+ writer.call(cache_key, counter + [ 1, options[:increment].to_i ].max)
69
49
  end
70
50
  end
71
51
 
72
- def reset(options)
73
- cache_key = sanitized_prop_key(options)
52
+ def throttled?(handle, key = nil, options = {})
53
+ options = sanitized_prop_options(handle, key, options)
54
+ cache_key = sanitized_prop_key(key, options[:interval])
55
+
56
+ reader.call(cache_key).to_i >= options[:threshold]
57
+ end
58
+
59
+ def reset(handle, key = nil, options = {})
60
+ options = sanitized_prop_options(handle, key, options)
61
+ cache_key = sanitized_prop_key(key, options[:interval])
62
+
74
63
  writer.call(cache_key, 0)
75
64
  end
76
65
 
77
- def count(options)
78
- cache_key = sanitized_prop_key(options)
66
+ def query(handle, key = nil, options = {})
67
+ options = sanitized_prop_options(handle, key, options)
68
+ cache_key = sanitized_prop_key(key, options[:interval])
69
+
79
70
  reader.call(cache_key).to_i
80
71
  end
81
72
 
82
73
  private
83
74
 
84
75
  # Builds the expiring cache key
85
- def sanitized_prop_key(options)
86
- window = (Time.now.to_i / options[:interval]) + options[:window_modifier].to_i
87
- cache_key = "#{normalize_cache_key(options[:key])}/#{ window }"
76
+ def sanitized_prop_key(key, interval)
77
+ window = (Time.now.to_i / interval)
78
+ cache_key = "#{normalize_cache_key(key)}/#{ window }"
88
79
  "prop/#{Digest::MD5.hexdigest(cache_key)}"
89
80
  end
90
81
 
91
82
  # Sanitizes the option set and sets defaults
92
- def sanitized_prop_options(args, defaults)
93
- options = args.last.is_a?(Hash) ? args.pop : {}
83
+ def sanitized_prop_options(handle, key, options)
84
+ defaults = (handles || {})[handle] || {}
94
85
  return {
95
- :key => normalize_cache_key(args), :message => defaults[:message], :progressive => defaults[:progressive],
96
- :threshold => defaults[:threshold].to_i, :interval => defaults[:interval].to_i, :increment => defaults[:increment]
86
+ :key => normalize_cache_key(key),
87
+ :increment => defaults[:increment],
88
+ :threshold => defaults[:threshold].to_i,
89
+ :interval => defaults[:interval].to_i
97
90
  }.merge(options)
98
91
  end
99
92
 
data/prop.gemspec CHANGED
@@ -5,11 +5,11 @@
5
5
 
6
6
  Gem::Specification.new do |s|
7
7
  s.name = %q{prop}
8
- s.version = "0.4.1"
8
+ s.version = "0.5.1"
9
9
 
10
10
  s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
11
  s.authors = ["Morten Primdahl"]
12
- s.date = %q{2010-12-08}
12
+ s.date = %q{2011-02-03}
13
13
  s.description = %q{A gem for implementing rate limiting}
14
14
  s.email = %q{morten@zendesk.com}
15
15
  s.extra_rdoc_files = [
@@ -18,6 +18,7 @@ Gem::Specification.new do |s|
18
18
  ]
19
19
  s.files = [
20
20
  ".document",
21
+ ".gemtest",
21
22
  "LICENSE",
22
23
  "README.rdoc",
23
24
  "Rakefile",
data/test/test_prop.rb CHANGED
@@ -12,167 +12,125 @@ class TestProp < Test::Unit::TestCase
12
12
  Time.stubs(:now).returns(@start)
13
13
  end
14
14
 
15
- context "#configure" do
15
+ context "#defaults" do
16
16
  should "raise errors on invalid configuation" do
17
17
  assert_raises(RuntimeError) do
18
- Prop.setup :hello_there, :threshold => 20, :interval => 'hello'
18
+ Prop.defaults :hello_there, :threshold => 20, :interval => 'hello'
19
19
  end
20
20
 
21
21
  assert_raises(RuntimeError) do
22
- Prop.setup :hello_there, :threshold => 'wibble', :interval => 100
22
+ Prop.defaults :hello_there, :threshold => 'wibble', :interval => 100
23
23
  end
24
24
  end
25
25
 
26
- should "accept a handle and an options hash" do
27
- Prop.setup :hello_there, :threshold => 40, :interval => 100
28
- assert Prop.respond_to?(:throttle_hello_there!)
29
- assert Prop.respond_to?(:throttle_hello_there?)
30
- assert Prop.respond_to?(:reset_hello_there)
31
- end
32
-
33
26
  should "result in a default handle" do
34
- Prop.setup :hello_there, :threshold => 4, :interval => 10
27
+ Prop.defaults :hello_there, :threshold => 4, :interval => 10
35
28
  4.times do |i|
36
- assert_equal (i + 1), Prop.throttle_hello_there!('some key')
29
+ assert_equal (i + 1), Prop.throttle!(:hello_there, 'some key')
37
30
  end
38
31
 
39
- assert_raises(Prop::RateLimitExceededError) { Prop.throttle_hello_there!('some key') }
40
- assert_equal 5, Prop.throttle_hello_there!('some key', :threshold => 20)
32
+ assert_raises(Prop::RateLimitExceededError) { Prop.throttle!(:hello_there, 'some key') }
33
+ assert_equal 5, Prop.throttle!(:hello_there, 'some key', :threshold => 20)
41
34
  end
42
35
 
43
36
  should "create a handle accepts various cache key types" do
44
- Prop.setup :hello_there, :threshold => 4, :interval => 10
45
- assert_equal 1, Prop.throttle_hello_there!(5)
46
- assert_equal 2, Prop.throttle_hello_there!(5)
47
- assert_equal 1, Prop.throttle_hello_there!('mister')
48
- assert_equal 2, Prop.throttle_hello_there!('mister')
49
- assert_equal 1, Prop.throttle_hello_there!('mister', 5)
50
- assert_equal 2, Prop.throttle_hello_there!('mister', 5)
51
- end
52
-
53
- should "not shadow undefined methods" do
54
- assert_raises(NoMethodError) { Prop.no_such_handle }
37
+ Prop.defaults :hello_there, :threshold => 4, :interval => 10
38
+ assert_equal 1, Prop.throttle!(:hello_there, 5)
39
+ assert_equal 2, Prop.throttle!(:hello_there, 5)
40
+ assert_equal 1, Prop.throttle!(:hello_there, '6')
41
+ assert_equal 2, Prop.throttle!(:hello_there, '6')
42
+ assert_equal 1, Prop.throttle!(:hello_there, [ 5, '6' ])
43
+ assert_equal 2, Prop.throttle!(:hello_there, [ 5, '6' ])
55
44
  end
56
45
  end
57
46
 
58
47
  context "#reset" do
59
48
  setup do
60
- Prop.setup :hello, :threshold => 10, :interval => 10
49
+ Prop.defaults :hello, :threshold => 10, :interval => 10
61
50
 
62
51
  5.times do |i|
63
- assert_equal (i + 1), Prop.throttle_hello!
52
+ assert_equal (i + 1), Prop.throttle!(:hello)
64
53
  end
65
54
  end
66
55
 
67
56
  should "set the correct counter to 0" do
68
- Prop.throttle_hello!('wibble')
69
- Prop.throttle_hello!('wibble')
70
-
71
- Prop.reset_hello
72
- assert_equal 1, Prop.throttle_hello!
57
+ Prop.throttle!(:hello, 'wibble')
58
+ Prop.throttle!(:hello, 'wibble')
73
59
 
74
- assert_equal 3, Prop.throttle_hello!('wibble')
75
- Prop.reset_hello('wibble')
76
- assert_equal 1, Prop.throttle_hello!('wibble')
77
- end
60
+ Prop.reset(:hello)
61
+ assert_equal 1, Prop.throttle!(:hello)
78
62
 
79
- should "be directly invokable" do
80
- Prop.reset :key => :hello, :threshold => 10, :interval => 10
81
- assert_equal 1, Prop.throttle_hello!
63
+ assert_equal 3, Prop.throttle!(:hello, 'wibble')
64
+ Prop.reset(:hello, 'wibble')
65
+ assert_equal 1, Prop.throttle!(:hello, 'wibble')
82
66
  end
83
67
  end
84
68
 
85
- context "#throttle?" do
69
+ context "#throttled?" do
86
70
  should "return true once the threshold has been reached" do
87
- Prop.throttle!(:key => 'hello', :threshold => 2, :interval => 10)
88
- assert !Prop.throttle?(:key => 'hello', :threshold => 2, :interval => 10)
89
-
90
- Prop.throttle!(:key => 'hello', :threshold => 2, :interval => 10)
91
- assert Prop.throttle?(:key => 'hello', :threshold => 2, :interval => 10)
71
+ Prop.defaults(:hello, :threshold => 2, :interval => 10)
72
+ Prop.throttle!(:hello)
73
+ assert !Prop.throttled?(:hello)
74
+ Prop.throttle!(:hello)
75
+ assert Prop.throttled?(:hello)
92
76
  end
93
77
  end
94
78
 
95
79
  context "#throttle!" do
96
80
  should "increment counter correctly" do
97
81
  3.times do |i|
98
- assert_equal (i + 1), Prop.throttle!(:key => 'hello', :threshold => 10, :interval => 10)
82
+ assert_equal (i + 1), Prop.throttle!(:hello, nil, :threshold => 10, :interval => 10)
99
83
  end
100
84
  end
101
85
 
102
86
  should "reset counter when time window is passed" do
103
87
  3.times do |i|
104
- assert_equal (i + 1), Prop.throttle!(:key => 'hello', :threshold => 10, :interval => 10)
88
+ assert_equal (i + 1), Prop.throttle!(:hello, nil, :threshold => 10, :interval => 10)
105
89
  end
106
90
 
107
91
  Time.stubs(:now).returns(@start + 20)
108
92
 
109
93
  3.times do |i|
110
- assert_equal (i + 1), Prop.throttle!(:key => 'hello', :threshold => 10, :interval => 10)
94
+ assert_equal (i + 1), Prop.throttle!(:hello, nil, :threshold => 10, :interval => 10)
111
95
  end
112
96
  end
113
97
 
114
- should "not reset counter when violations happen during the time window for a progressive cache" do
115
- start = Time.parse("2006-05-04 03:02:01")
116
- Time.stubs(:now).returns(start)
117
-
118
- Prop.setup :not_progressive, :threshold => 5, :interval => 10
119
-
120
- assert_raises(Prop::RateLimitExceededError) do
121
- 6.times { Prop.throttle_not_progressive! }
122
- end
123
-
124
- Time.stubs(:now).returns(start + 5)
125
- assert_raises(Prop::RateLimitExceededError) { Prop.throttle_not_progressive! }
126
- Time.stubs(:now).returns(start + 15)
127
- assert Prop.throttle_not_progressive!
128
-
129
- Time.stubs(:now).returns(start)
130
- Prop.setup :progressive, :threshold => 5, :interval => 10, :progressive => true
131
-
132
- assert_raises(Prop::RateLimitExceededError) do
133
- 6.times { Prop.throttle_progressive! }
134
- end
135
-
136
- Time.stubs(:now).returns(start + 5)
137
- assert_raises(Prop::RateLimitExceededError) { Prop.throttle_progressive! }
138
-
139
- Time.stubs(:now).returns(start + 11)
140
- assert_raises(Prop::RateLimitExceededError) { Prop.throttle_progressive! }
141
- end
142
-
143
98
  should "not increment the counter beyond the threshold" do
99
+ Prop.defaults(:hello, :threshold => 5, :interval => 1)
144
100
  10.times do |i|
145
- Prop.throttle!(:key => 'hello', :threshold => 5, :interval => 10) rescue nil
101
+ Prop.throttle!(:hello) rescue nil
146
102
  end
147
103
 
148
- assert_equal 5, Prop.count(:key => 'hello', :threshold => 5, :interval => 10)
104
+ assert_equal 5, Prop.query(:hello)
149
105
  end
150
106
 
151
107
  should "support custom increments" do
152
- Prop.throttle!(:key => 'hello', :threshold => 100, :interval => 10)
153
- Prop.throttle!(:key => 'hello', :threshold => 100, :interval => 10)
108
+ Prop.defaults(:hello, :threshold => 100, :interval => 10)
109
+
110
+ Prop.throttle!(:hello)
111
+ Prop.throttle!(:hello)
154
112
 
155
- assert_equal 2, Prop.count(:key => 'hello', :threshold => 100, :interval => 10)
113
+ assert_equal 2, Prop.query(:hello)
156
114
 
157
- Prop.throttle!(:key => 'hello', :threshold => 100, :interval => 10, :increment => 48)
115
+ Prop.throttle!(:hello, nil, :increment => 48)
158
116
 
159
- assert_equal 50, Prop.count(:key => 'hello', :threshold => 100, :interval => 10)
117
+ assert_equal 50, Prop.query(:hello)
160
118
  end
161
119
 
162
120
  should "raise Prop::RateLimitExceededError when the threshold is exceeded" do
163
121
  5.times do |i|
164
- Prop.throttle!(:key => 'hello', :threshold => 5, :interval => 10)
122
+ Prop.throttle!(:hello, nil, :threshold => 5, :interval => 10)
165
123
  end
166
124
  assert_raises(Prop::RateLimitExceededError) do
167
- Prop.throttle!(:key => 'hello', :threshold => 5, :interval => 10)
125
+ Prop.throttle!(:hello, nil, :threshold => 5, :interval => 10)
168
126
  end
169
127
 
170
128
  begin
171
- Prop.throttle!(:key => 'hello', :threshold => 5, :interval => 10, :message => "Wibble")
129
+ Prop.throttle!(:hello, nil, :threshold => 5, :interval => 10)
172
130
  fail
173
131
  rescue Prop::RateLimitExceededError => e
174
- assert_equal "Wibble", e.message
175
- assert_equal "hello threshold 5 exceeded", e.root_message
132
+ assert_equal :hello, e.handle
133
+ assert_equal "hello threshold of 5 exceeded for key ''", e.message
176
134
  assert e.retry_after
177
135
  end
178
136
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: prop
3
3
  version: !ruby/object:Gem::Version
4
- hash: 13
4
+ hash: 9
5
5
  prerelease: false
6
6
  segments:
7
7
  - 0
8
- - 4
8
+ - 5
9
9
  - 1
10
- version: 0.4.1
10
+ version: 0.5.1
11
11
  platform: ruby
12
12
  authors:
13
13
  - Morten Primdahl
@@ -15,7 +15,7 @@ autorequire:
15
15
  bindir: bin
16
16
  cert_chain: []
17
17
 
18
- date: 2010-12-08 00:00:00 -08:00
18
+ date: 2011-02-03 00:00:00 -08:00
19
19
  default_executable:
20
20
  dependencies:
21
21
  - !ruby/object:Gem::Dependency
@@ -59,6 +59,7 @@ extra_rdoc_files:
59
59
  - README.rdoc
60
60
  files:
61
61
  - .document
62
+ - .gemtest
62
63
  - LICENSE
63
64
  - README.rdoc
64
65
  - Rakefile