prop 0.4.1 → 0.5.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gemtest +0 -0
- data/README.rdoc +29 -30
- data/VERSION +1 -1
- data/lib/prop.rb +39 -46
- data/prop.gemspec +3 -2
- data/test/test_prop.rb +47 -89
- 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.
|
20
|
+
Prop.defaults(:mails_per_hour, :threshold => 100, :interval => 1.hour)
|
21
21
|
|
22
|
-
|
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.
|
25
|
+
Prop.throttle!(:mails_per_hour)
|
26
26
|
|
27
27
|
# Returns true if the threshold/interval has been reached
|
28
|
-
Prop.
|
28
|
+
Prop.throttled?(:mails_per_hour)
|
29
29
|
|
30
|
-
# Sets the
|
31
|
-
Prop.
|
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.
|
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
|
-
|
45
|
+
Prop.throttle!(:mails_per_hour, [ account.id, mail.from ])
|
38
46
|
|
39
|
-
|
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
|
-
|
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.
|
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.
|
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
|
-
|
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
|
-
|
62
|
+
Prop.throttle!(:mails_per_hour)
|
63
|
+
Prop.throttle!(:mails_per_hour, nil)
|
63
64
|
|
64
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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.
|
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 :
|
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
|
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")
|
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
|
-
|
51
|
-
|
52
|
-
end
|
36
|
+
self.handles ||= {}
|
37
|
+
self.handles[handle] = defaults
|
53
38
|
end
|
54
39
|
|
55
|
-
def throttle
|
56
|
-
|
57
|
-
|
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
|
-
|
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(
|
48
|
+
writer.call(cache_key, counter + [ 1, options[:increment].to_i ].max)
|
69
49
|
end
|
70
50
|
end
|
71
51
|
|
72
|
-
def
|
73
|
-
|
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
|
78
|
-
|
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(
|
86
|
-
window = (Time.now.to_i /
|
87
|
-
cache_key = "#{normalize_cache_key(
|
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(
|
93
|
-
|
83
|
+
def sanitized_prop_options(handle, key, options)
|
84
|
+
defaults = (handles || {})[handle] || {}
|
94
85
|
return {
|
95
|
-
:key
|
96
|
-
:
|
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.
|
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{
|
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 "#
|
15
|
+
context "#defaults" do
|
16
16
|
should "raise errors on invalid configuation" do
|
17
17
|
assert_raises(RuntimeError) do
|
18
|
-
Prop.
|
18
|
+
Prop.defaults :hello_there, :threshold => 20, :interval => 'hello'
|
19
19
|
end
|
20
20
|
|
21
21
|
assert_raises(RuntimeError) do
|
22
|
-
Prop.
|
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.
|
27
|
+
Prop.defaults :hello_there, :threshold => 4, :interval => 10
|
35
28
|
4.times do |i|
|
36
|
-
assert_equal (i + 1), Prop.
|
29
|
+
assert_equal (i + 1), Prop.throttle!(:hello_there, 'some key')
|
37
30
|
end
|
38
31
|
|
39
|
-
assert_raises(Prop::RateLimitExceededError) { Prop.
|
40
|
-
assert_equal 5, Prop.
|
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.
|
45
|
-
assert_equal 1, Prop.
|
46
|
-
assert_equal 2, Prop.
|
47
|
-
assert_equal 1, Prop.
|
48
|
-
assert_equal 2, Prop.
|
49
|
-
assert_equal 1, Prop.
|
50
|
-
assert_equal 2, Prop.
|
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.
|
49
|
+
Prop.defaults :hello, :threshold => 10, :interval => 10
|
61
50
|
|
62
51
|
5.times do |i|
|
63
|
-
assert_equal (i + 1), Prop.
|
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.
|
69
|
-
Prop.
|
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
|
-
|
75
|
-
Prop.
|
76
|
-
assert_equal 1, Prop.throttle_hello!('wibble')
|
77
|
-
end
|
60
|
+
Prop.reset(:hello)
|
61
|
+
assert_equal 1, Prop.throttle!(:hello)
|
78
62
|
|
79
|
-
|
80
|
-
Prop.reset
|
81
|
-
assert_equal 1, Prop.
|
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 "#
|
69
|
+
context "#throttled?" do
|
86
70
|
should "return true once the threshold has been reached" do
|
87
|
-
Prop.
|
88
|
-
|
89
|
-
|
90
|
-
Prop.throttle!(:
|
91
|
-
assert Prop.
|
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!(:
|
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!(:
|
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!(:
|
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!(:
|
101
|
+
Prop.throttle!(:hello) rescue nil
|
146
102
|
end
|
147
103
|
|
148
|
-
assert_equal 5, Prop.
|
104
|
+
assert_equal 5, Prop.query(:hello)
|
149
105
|
end
|
150
106
|
|
151
107
|
should "support custom increments" do
|
152
|
-
Prop.
|
153
|
-
|
108
|
+
Prop.defaults(:hello, :threshold => 100, :interval => 10)
|
109
|
+
|
110
|
+
Prop.throttle!(:hello)
|
111
|
+
Prop.throttle!(:hello)
|
154
112
|
|
155
|
-
assert_equal 2, Prop.
|
113
|
+
assert_equal 2, Prop.query(:hello)
|
156
114
|
|
157
|
-
Prop.throttle!(:
|
115
|
+
Prop.throttle!(:hello, nil, :increment => 48)
|
158
116
|
|
159
|
-
assert_equal 50, Prop.
|
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!(:
|
122
|
+
Prop.throttle!(:hello, nil, :threshold => 5, :interval => 10)
|
165
123
|
end
|
166
124
|
assert_raises(Prop::RateLimitExceededError) do
|
167
|
-
Prop.throttle!(:
|
125
|
+
Prop.throttle!(:hello, nil, :threshold => 5, :interval => 10)
|
168
126
|
end
|
169
127
|
|
170
128
|
begin
|
171
|
-
Prop.throttle!(:
|
129
|
+
Prop.throttle!(:hello, nil, :threshold => 5, :interval => 10)
|
172
130
|
fail
|
173
131
|
rescue Prop::RateLimitExceededError => e
|
174
|
-
assert_equal
|
175
|
-
assert_equal "hello threshold 5 exceeded", e.
|
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:
|
4
|
+
hash: 9
|
5
5
|
prerelease: false
|
6
6
|
segments:
|
7
7
|
- 0
|
8
|
-
-
|
8
|
+
- 5
|
9
9
|
- 1
|
10
|
-
version: 0.
|
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:
|
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
|