prop 1.0.0 → 1.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/Gemfile +1 -1
- data/README.md +82 -50
- data/lib/prop.rb +1 -1
- data/lib/prop/limiter.rb +9 -1
- data/prop.gemspec +3 -3
- data/test/helper.rb +7 -6
- data/test/test_key.rb +16 -18
- data/test/test_limiter.rb +74 -57
- data/test/test_middleware.rb +29 -30
- data/test/test_options.rb +30 -34
- data/test/test_prop.rb +140 -143
- data/test/test_rate_limited.rb +17 -20
- metadata +2 -2
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -3,73 +3,95 @@
|
|
3
3
|
|
4
4
|
Prop is a simple gem for rate limiting requests of any kind. It allows you to configure hooks for registering certain actions, such that you can define thresholds, register usage and finally act on exceptions once thresholds get exceeded.
|
5
5
|
|
6
|
-
Prop uses
|
6
|
+
Prop uses an 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 two times the specified requests within the specified interval.
|
7
7
|
|
8
|
-
To get going with Prop you first define the read and write operations. These define how you write a registered request and how to read the number of requests for a given action. For example do something like the below in a Rails initializer:
|
8
|
+
To get going with Prop, you first define the read and write operations. These define how you write a registered request and how to read the number of requests for a given action. For example, do something like the below in a Rails initializer:
|
9
9
|
|
10
|
-
|
11
|
-
|
12
|
-
|
10
|
+
```ruby
|
11
|
+
Prop.read do |key|
|
12
|
+
Rails.cache.read(key)
|
13
|
+
end
|
13
14
|
|
14
|
-
|
15
|
-
|
16
|
-
|
15
|
+
Prop.write do |key, value|
|
16
|
+
Rails.cache.write(key, value)
|
17
|
+
end
|
18
|
+
```
|
17
19
|
|
18
20
|
You can choose to rely on whatever you'd like to use for transient storage. Prop does not do any sort of clean up of its key space, so you would have to implement that manually should you be using anything but an LRU cache like memcached.
|
19
21
|
|
22
|
+
## Setting a Callback
|
23
|
+
|
24
|
+
You can define an optional callback that is invoked when a rate limit is reached. In a Rails application you could use such a handler to add notification support:
|
25
|
+
|
26
|
+
```ruby
|
27
|
+
Prop.before_throttle do |handle, key, threshold, interval|
|
28
|
+
ActiveSupport::Notifications.instrument('throttle.prop', handle: handle, key: key, threshold: threshold, interval: interval)
|
29
|
+
end
|
30
|
+
```
|
31
|
+
|
20
32
|
## Defining thresholds
|
21
33
|
|
22
|
-
Once the read and write operations are defined, you can optionally define 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:
|
34
|
+
Once the read and write operations are defined, you can optionally define 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:
|
23
35
|
|
24
|
-
|
36
|
+
```ruby
|
37
|
+
Prop.configure(:mails_per_hour, :threshold => 100, :interval => 1.hour, :description => "Mail rate limit exceeded")
|
38
|
+
```
|
25
39
|
|
26
|
-
The `:mails_per_hour` in the above is called the "handle". You can now put the throttle to work with
|
40
|
+
The `:mails_per_hour` in the above is called the "handle". You can now put the throttle to work with these values, by passing the handle to the respective methods in Prop:
|
27
41
|
|
28
|
-
|
29
|
-
|
42
|
+
```ruby
|
43
|
+
# Throws Prop::RateLimitExceededError if the threshold/interval has been reached
|
44
|
+
Prop.throttle!(:mails_per_hour)
|
30
45
|
|
31
|
-
|
32
|
-
|
46
|
+
# Prop can be used to guard a block of code
|
47
|
+
Prop.throttle!(:expensive_request) { calculator.something_very_hard }
|
33
48
|
|
34
|
-
|
35
|
-
|
49
|
+
# Returns true if the threshold/interval has been reached
|
50
|
+
Prop.throttled?(:mails_per_hour)
|
36
51
|
|
37
|
-
|
38
|
-
|
52
|
+
# Sets the throttle "count" to 0
|
53
|
+
Prop.reset(:mails_per_hour)
|
39
54
|
|
40
|
-
|
41
|
-
|
55
|
+
# Returns the value of this throttle, usually a count, but see below for more
|
56
|
+
Prop.count(:mails_per_hour)
|
57
|
+
```
|
42
58
|
|
43
|
-
Prop will raise a RuntimeError if you attempt to operate on an undefined handle.
|
59
|
+
Prop will raise a `RuntimeError` if you attempt to operate on an undefined handle.
|
44
60
|
|
45
61
|
## Scoping a throttle
|
46
62
|
|
47
|
-
In many cases you will want to tie a specific key to a defined throttle
|
63
|
+
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:
|
48
64
|
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
65
|
+
```ruby
|
66
|
+
Prop.throttle!(:mails_per_hour, mail.from)
|
67
|
+
Prop.throttled?(:mails_per_hour, mail.from)
|
68
|
+
Prop.reset(:mails_per_hour, mail.from)
|
69
|
+
Prop.query(:mails_per_hour, mail.from)
|
70
|
+
```
|
53
71
|
|
54
72
|
The throttle scope can also be an array of values, e.g.:
|
55
73
|
|
56
|
-
|
74
|
+
```ruby
|
75
|
+
Prop.throttle!(:mails_per_hour, [ account.id, mail.from ])
|
76
|
+
```
|
57
77
|
|
58
78
|
## Error handling
|
59
79
|
|
60
|
-
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::RateLimited error which is a subclass of StandardError
|
80
|
+
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::RateLimited` error which is a subclass of `StandardError`. This exception contains a "handle" reference and a "description" if specified during the configuration. The handle allows you to rescue `Prop::RateLimited` and differentiate action depending on the handle. For example, in Rails you can use this in e.g. `ApplicationController`:
|
61
81
|
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
82
|
+
```ruby
|
83
|
+
rescue_from Prop::RateLimited do |e|
|
84
|
+
if e.handle == :authorization_attempt
|
85
|
+
render :status => :forbidden, :message => I18n.t(e.description)
|
86
|
+
elsif ...
|
66
87
|
|
67
|
-
|
68
|
-
|
88
|
+
end
|
89
|
+
end
|
90
|
+
```
|
69
91
|
|
70
92
|
### Using the Middleware
|
71
93
|
|
72
|
-
Prop ships with a built
|
94
|
+
Prop ships with a built-in Rack middleware that you can use to do all the exception handling. When a `Prop::RateLimited` error is caught, it will build an HTTP [429 Too Many Requests](http://tools.ietf.org/html/draft-nottingham-http-new-status-02#section-4) response and set the following headers:
|
73
95
|
|
74
96
|
Retry-After: 32
|
75
97
|
Content-Type: text/plain
|
@@ -79,14 +101,16 @@ Where `Retry-After` is the number of seconds the client has to wait before retry
|
|
79
101
|
|
80
102
|
If you wish to do manual error messaging in these cases, you can define an error handler in your Prop configuration. Here's how the default error handler looks - you use anything that responds to `.call` and takes the environment and a `RateLimited` instance as argument:
|
81
103
|
|
82
|
-
|
83
|
-
|
84
|
-
|
104
|
+
```ruby
|
105
|
+
error_handler = Proc.new do |env, error|
|
106
|
+
body = error.description || "This action has been rate limited"
|
107
|
+
headers = { "Content-Type" => "text/plain", "Content-Length" => body.size, "Retry-After" => error.retry_after }
|
85
108
|
|
86
|
-
|
87
|
-
|
109
|
+
[ 429, headers, [ body ]]
|
110
|
+
end
|
88
111
|
|
89
|
-
|
112
|
+
ActionController::Dispatcher.middleware.insert_before(ActionController::ParamsParser, :error_handler => error_handler)
|
113
|
+
```
|
90
114
|
|
91
115
|
An alternative to this, is to extend `Prop::Middleware` and override the `render_response(env, error)` method.
|
92
116
|
|
@@ -94,25 +118,33 @@ An alternative to this, is to extend `Prop::Middleware` and override the `render
|
|
94
118
|
|
95
119
|
In case you need to perform e.g. a manual bulk operation:
|
96
120
|
|
97
|
-
|
98
|
-
|
99
|
-
|
121
|
+
```ruby
|
122
|
+
Prop.disabled do
|
123
|
+
# No throttles will be tested here
|
124
|
+
end
|
125
|
+
```
|
100
126
|
|
101
127
|
## Threshold settings
|
102
128
|
|
103
129
|
You can chose to override the threshold for a given key:
|
104
130
|
|
105
|
-
|
131
|
+
```ruby
|
132
|
+
Prop.throttle!(:mails_per_hour, mail.from, :threshold => current_account.mail_throttle_threshold)
|
133
|
+
```
|
106
134
|
|
107
135
|
When the threshold are invoked without argument, the key is nil and as such a scope of its own, i.e. these are equivalent:
|
108
136
|
|
109
|
-
|
110
|
-
|
137
|
+
```ruby
|
138
|
+
Prop.throttle!(:mails_per_hour)
|
139
|
+
Prop.throttle!(:mails_per_hour, nil)
|
140
|
+
```
|
111
141
|
|
112
142
|
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:
|
113
143
|
|
114
|
-
|
115
|
-
|
144
|
+
```ruby
|
145
|
+
Prop.setup(:execute_time, :threshold => 10, :interval => 1.minute)
|
146
|
+
Prop.throttle!(:execute_time, account.id, :increment => (Benchmark.realtime { execute }).to_i)
|
147
|
+
```
|
116
148
|
|
117
149
|
## License
|
118
150
|
|
data/lib/prop.rb
CHANGED
@@ -7,7 +7,7 @@ module Prop
|
|
7
7
|
# Short hand for accessing Prop::Limiter methods
|
8
8
|
class << self
|
9
9
|
extend Forwardable
|
10
|
-
def_delegators :"Prop::Limiter", :read, :write, :configure, :disabled
|
10
|
+
def_delegators :"Prop::Limiter", :read, :write, :configure, :disabled, :before_throttle
|
11
11
|
def_delegators :"Prop::Limiter", :throttle!, :throttled?, :count, :query, :reset
|
12
12
|
end
|
13
13
|
end
|
data/lib/prop/limiter.rb
CHANGED
@@ -6,7 +6,7 @@ module Prop
|
|
6
6
|
class Limiter
|
7
7
|
|
8
8
|
class << self
|
9
|
-
attr_accessor :handles, :reader, :writer
|
9
|
+
attr_accessor :handles, :reader, :writer, :before_throttle_callback
|
10
10
|
|
11
11
|
def read(&blk)
|
12
12
|
self.reader = blk
|
@@ -16,6 +16,10 @@ module Prop
|
|
16
16
|
self.writer = blk
|
17
17
|
end
|
18
18
|
|
19
|
+
def before_throttle(&blk)
|
20
|
+
self.before_throttle_callback = blk
|
21
|
+
end
|
22
|
+
|
19
23
|
# Public: Registers a handle for rate limiting
|
20
24
|
#
|
21
25
|
# handle - the name of the handle you wish to use in your code, e.g. :login_attempt
|
@@ -55,6 +59,10 @@ module Prop
|
|
55
59
|
|
56
60
|
unless disabled?
|
57
61
|
if at_threshold?(counter, options[:threshold])
|
62
|
+
unless before_throttle_callback.nil?
|
63
|
+
before_throttle_callback.call(handle, key, options[:threshold], options[:interval])
|
64
|
+
end
|
65
|
+
|
58
66
|
raise Prop::RateLimited.new(options.merge(:cache_key => cache_key, :handle => handle))
|
59
67
|
else
|
60
68
|
increment = options.key?(:increment) ? options[:increment].to_i : 1
|
data/prop.gemspec
CHANGED
@@ -1,6 +1,6 @@
|
|
1
|
-
Gem::Specification.new "prop", "1.0.
|
1
|
+
Gem::Specification.new "prop", "1.0.1" do |s|
|
2
2
|
s.name = 'prop'
|
3
|
-
s.version = '1.0.
|
3
|
+
s.version = '1.0.1'
|
4
4
|
s.date = '2012-04-24'
|
5
5
|
s.rubyforge_project = 'prop'
|
6
6
|
s.license = "Apache License Version 2.0"
|
@@ -18,7 +18,7 @@ Gem::Specification.new "prop", "1.0.0" do |s|
|
|
18
18
|
|
19
19
|
s.add_development_dependency('rake')
|
20
20
|
s.add_development_dependency('bundler')
|
21
|
-
s.add_development_dependency('
|
21
|
+
s.add_development_dependency('minitest')
|
22
22
|
s.add_development_dependency('mocha')
|
23
23
|
|
24
24
|
s.files = `git ls-files`.split("\n")
|
data/test/helper.rb
CHANGED
@@ -1,12 +1,13 @@
|
|
1
1
|
require 'rubygems'
|
2
|
-
|
3
|
-
require
|
4
|
-
require
|
2
|
+
|
3
|
+
require "minitest/spec"
|
4
|
+
require "minitest/mock"
|
5
|
+
require "minitest/autorun"
|
6
|
+
require 'mocha/setup'
|
7
|
+
|
5
8
|
require 'time'
|
6
9
|
|
7
10
|
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
8
11
|
$LOAD_PATH.unshift(File.dirname(__FILE__))
|
9
|
-
require 'prop'
|
10
12
|
|
11
|
-
|
12
|
-
end
|
13
|
+
require 'prop'
|
data/test/test_key.rb
CHANGED
@@ -1,26 +1,24 @@
|
|
1
1
|
require 'helper'
|
2
2
|
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
should "return a hexdigested key" do
|
8
|
-
assert_match /prop\/[a-f0-9]+/, Prop::Key.build(:handle => :hello, :key => [ "foo", 2, :bar ], :interval => 60)
|
9
|
-
end
|
3
|
+
describe Prop::Key do
|
4
|
+
describe "#build" do
|
5
|
+
it "return a hexdigested key" do
|
6
|
+
assert_match /prop\/[a-f0-9]+/, Prop::Key.build(:handle => :hello, :key => [ "foo", 2, :bar ], :interval => 60)
|
10
7
|
end
|
8
|
+
end
|
11
9
|
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
10
|
+
describe "#normalize" do
|
11
|
+
it "turn a Fixnum into a String" do
|
12
|
+
assert_equal "3", Prop::Key.normalize(3)
|
13
|
+
end
|
16
14
|
|
17
|
-
|
18
|
-
|
19
|
-
|
15
|
+
it "return a String" do
|
16
|
+
assert_equal "S", Prop::Key.normalize("S")
|
17
|
+
end
|
20
18
|
|
21
|
-
|
22
|
-
|
23
|
-
end
|
19
|
+
it "flatten and join an Array" do
|
20
|
+
assert_equal "1/B/3", Prop::Key.normalize([ 1, "B", "3" ])
|
24
21
|
end
|
25
22
|
end
|
26
|
-
end
|
23
|
+
end
|
24
|
+
|
data/test/test_limiter.rb
CHANGED
@@ -1,90 +1,107 @@
|
|
1
1
|
require 'helper'
|
2
2
|
|
3
|
-
class TestLimiter < Test::Unit::TestCase
|
4
3
|
|
5
|
-
|
6
|
-
|
7
|
-
|
4
|
+
describe Prop::Limiter do
|
5
|
+
before do
|
6
|
+
@store = {}
|
8
7
|
|
9
|
-
|
10
|
-
|
11
|
-
|
8
|
+
Prop::Limiter.read { |key| @store[key] }
|
9
|
+
Prop::Limiter.write { |key, value| @store[key] = value }
|
10
|
+
Prop::Limiter.configure(:something, :threshold => 10, :interval => 10)
|
12
11
|
|
13
|
-
|
14
|
-
|
15
|
-
|
12
|
+
@start = Time.now
|
13
|
+
Time.stubs(:now).returns(@start)
|
14
|
+
end
|
16
15
|
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
16
|
+
describe "#throttle!" do
|
17
|
+
before do
|
18
|
+
Prop.reset(:something)
|
19
|
+
end
|
21
20
|
|
22
|
-
|
23
|
-
|
21
|
+
describe "when disabled" do
|
22
|
+
before { Prop::Limiter.expects(:disabled?).returns(true) }
|
24
23
|
|
25
|
-
|
26
|
-
|
27
|
-
|
24
|
+
[ true, false ].each do |threshold_reached|
|
25
|
+
describe "and threshold has #{"not " unless threshold_reached}been reached" do
|
26
|
+
before { Prop::Limiter.stubs(:at_threshold?).returns(threshold_reached) }
|
28
27
|
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
end
|
28
|
+
describe "given a block" do
|
29
|
+
it "execute that block" do
|
30
|
+
assert_equal "wibble", Prop.throttle!(:something) { "wibble" }
|
33
31
|
end
|
32
|
+
end
|
34
33
|
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
end
|
34
|
+
describe "not given a block" do
|
35
|
+
it "return the current throttle count" do
|
36
|
+
assert_equal Prop.count(:something), Prop.throttle!(:something)
|
39
37
|
end
|
40
38
|
end
|
41
39
|
end
|
42
40
|
end
|
41
|
+
end
|
43
42
|
|
44
|
-
|
45
|
-
|
43
|
+
describe "when not disabled" do
|
44
|
+
before { Prop::Limiter.expects(:disabled?).returns(false) }
|
46
45
|
|
47
|
-
|
48
|
-
|
46
|
+
describe "and threshold has been reached" do
|
47
|
+
before { Prop::Limiter.expects(:at_threshold?).returns(true) }
|
49
48
|
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
49
|
+
describe "given a block" do
|
50
|
+
it "raise Prop::RateLimited" do
|
51
|
+
assert_raises(Prop::RateLimited) { Prop.throttle!(:something) { "wibble" }}
|
52
|
+
end
|
54
53
|
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
end
|
54
|
+
it "raise even if given :increment => 0" do
|
55
|
+
value = Prop.count(:something)
|
56
|
+
assert_raises(Prop::RateLimited) { Prop.throttle!(:something, nil, :increment => 0) { "wibble" }}
|
57
|
+
assert_equal value, Prop.count(:something)
|
60
58
|
end
|
61
59
|
|
62
|
-
|
63
|
-
|
64
|
-
|
60
|
+
describe "and given a before_throttle callback" do
|
61
|
+
before do
|
62
|
+
Prop.before_throttle do |handle, key, threshold, interval|
|
63
|
+
@handle = handle
|
64
|
+
@key = key
|
65
|
+
@threshold = threshold
|
66
|
+
@interval = interval
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
it "invoke callback with expected parameters" do
|
71
|
+
assert_raises(Prop::RateLimited) { Prop.throttle!(:something, [:extra]) }
|
72
|
+
assert_equal @handle, :something
|
73
|
+
assert_equal @key, [:extra]
|
74
|
+
assert_equal @threshold, 10
|
75
|
+
assert_equal @interval, 10
|
65
76
|
end
|
66
77
|
end
|
67
78
|
end
|
68
79
|
|
69
|
-
|
70
|
-
|
71
|
-
Prop::
|
80
|
+
describe "not given a block" do
|
81
|
+
it "raise Prop::RateLimited" do
|
82
|
+
assert_raises(Prop::RateLimited) { Prop.throttle!(:something) }
|
72
83
|
end
|
84
|
+
end
|
85
|
+
end
|
73
86
|
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
87
|
+
describe "and threshold has not been reached" do
|
88
|
+
before do
|
89
|
+
Prop::Limiter.expects(:at_threshold?).returns(false)
|
90
|
+
end
|
91
|
+
|
92
|
+
describe "given a block" do
|
93
|
+
it "execute that block" do
|
94
|
+
assert_equal "wibble", Prop.throttle!(:something) { "wibble" }
|
78
95
|
end
|
96
|
+
end
|
79
97
|
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
98
|
+
describe "not given a block" do
|
99
|
+
it "return the updated throttle count" do
|
100
|
+
assert_equal Prop.count(:something) + 1, Prop.throttle!(:something)
|
101
|
+
end
|
84
102
|
|
85
|
-
|
86
|
-
|
87
|
-
end
|
103
|
+
it "not update count if passed an increment of 0" do
|
104
|
+
assert_equal Prop.count(:something), Prop.throttle!(:something, nil, :increment => 0)
|
88
105
|
end
|
89
106
|
end
|
90
107
|
end
|
data/test/test_middleware.rb
CHANGED
@@ -1,47 +1,46 @@
|
|
1
1
|
require 'helper'
|
2
|
+
|
2
3
|
require 'prop/middleware'
|
3
4
|
require 'prop/rate_limited'
|
4
5
|
|
5
|
-
class TestMiddleware < Test::Unit::TestCase
|
6
6
|
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
7
|
+
describe Prop::Middleware do
|
8
|
+
before do
|
9
|
+
@app = stub()
|
10
|
+
@env = {}
|
11
|
+
@middleware = Prop::Middleware.new(@app)
|
12
|
+
end
|
13
|
+
|
14
|
+
describe "when the app call completes" do
|
15
|
+
before do
|
16
|
+
@app.expects(:call).with(@env).returns("response")
|
12
17
|
end
|
13
18
|
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
19
|
+
it "return the response" do
|
20
|
+
assert_equal "response", @middleware.call(@env)
|
21
|
+
end
|
22
|
+
end
|
18
23
|
|
19
|
-
|
20
|
-
|
21
|
-
|
24
|
+
describe "when the app call results in a raised throttle" do
|
25
|
+
before do
|
26
|
+
@app.expects(:call).with(@env).raises(Prop::RateLimited.new(:handle => "foo", :threshold => 10, :interval => 60, :cache_key => "wibble", :description => "Boom!"))
|
22
27
|
end
|
23
28
|
|
24
|
-
|
25
|
-
|
26
|
-
@app.expects(:call).with(@env).raises(Prop::RateLimited.new(:handle => "foo", :threshold => 10, :interval => 60, :cache_key => "wibble", :description => "Boom!"))
|
27
|
-
end
|
29
|
+
it "return the rate limited message" do
|
30
|
+
response = @middleware.call(@env)
|
28
31
|
|
29
|
-
|
30
|
-
|
32
|
+
assert_equal 429, response[0]
|
33
|
+
assert_equal ["Boom!"], response[2]
|
34
|
+
end
|
31
35
|
|
32
|
-
|
33
|
-
|
36
|
+
describe "with a custom error handler" do
|
37
|
+
before do
|
38
|
+
@middleware = Prop::Middleware.new(@app, :error_handler => Proc.new { |env, error| "Oops" })
|
34
39
|
end
|
35
40
|
|
36
|
-
|
37
|
-
|
38
|
-
@middleware = Prop::Middleware.new(@app, :error_handler => Proc.new { |env, error| "Oops" })
|
39
|
-
end
|
40
|
-
|
41
|
-
should "allow setting a custom error handler" do
|
42
|
-
assert_equal "Oops", @middleware.call(@env)
|
43
|
-
end
|
41
|
+
it "allow setting a custom error handler" do
|
42
|
+
assert_equal "Oops", @middleware.call(@env)
|
44
43
|
end
|
45
44
|
end
|
46
45
|
end
|
47
|
-
end
|
46
|
+
end
|
data/test/test_options.rb
CHANGED
@@ -1,48 +1,44 @@
|
|
1
1
|
require 'helper'
|
2
2
|
|
3
|
-
|
3
|
+
describe Prop::Options do
|
4
|
+
describe "#build" do
|
5
|
+
before do
|
6
|
+
@args = { :key => "hello", :params => { :foo => "bif" }, :defaults => { :foo => "bar", :baz => "moo", :threshold => 10, :interval => 5 }}
|
7
|
+
end
|
4
8
|
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
@args = { :key => "hello", :params => { :foo => "bif" }, :defaults => { :foo => "bar", :baz => "moo", :threshold => 10, :interval => 5 }}
|
9
|
+
describe "when given valid input" do
|
10
|
+
before do
|
11
|
+
@options = Prop::Options.build(@args)
|
9
12
|
end
|
10
13
|
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
end
|
15
|
-
|
16
|
-
should "support defaults" do
|
17
|
-
assert_equal "moo", @options[:baz]
|
18
|
-
end
|
14
|
+
it "support defaults" do
|
15
|
+
assert_equal "moo", @options[:baz]
|
16
|
+
end
|
19
17
|
|
20
|
-
|
21
|
-
|
22
|
-
end
|
18
|
+
it "override defaults" do
|
19
|
+
assert_equal "bif", @options[:foo]
|
23
20
|
end
|
21
|
+
end
|
24
22
|
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
23
|
+
describe "when given invalid input" do
|
24
|
+
it "raise when not given an interval" do
|
25
|
+
@args[:defaults].delete(:interval)
|
26
|
+
assert_raises(RuntimeError) { Prop::Options.build(@args) }
|
27
|
+
end
|
30
28
|
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
29
|
+
it "raise when not given a threshold" do
|
30
|
+
@args[:defaults].delete(:threshold)
|
31
|
+
assert_raises(RuntimeError) { Prop::Options.build(@args) }
|
32
|
+
end
|
35
33
|
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
end
|
34
|
+
it "raise when not given a key" do
|
35
|
+
@args.delete(:key)
|
36
|
+
begin
|
37
|
+
Prop::Options.build(@args)
|
38
|
+
fail "it puke when not given a valid key"
|
39
|
+
rescue
|
43
40
|
end
|
44
41
|
end
|
45
42
|
end
|
46
|
-
|
47
43
|
end
|
48
|
-
end
|
44
|
+
end
|
data/test/test_prop.rb
CHANGED
@@ -1,198 +1,195 @@
|
|
1
1
|
require 'helper'
|
2
2
|
|
3
3
|
# Integration level tests
|
4
|
-
|
4
|
+
describe Prop do
|
5
|
+
before do
|
6
|
+
store = {}
|
7
|
+
Prop.read { |key| store[key] }
|
8
|
+
Prop.write { |key, value| store[key] = value }
|
9
|
+
|
10
|
+
@start = Time.now
|
11
|
+
Time.stubs(:now).returns(@start)
|
12
|
+
end
|
5
13
|
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
14
|
+
describe "#defaults" do
|
15
|
+
it "raise errors on invalid configuation" do
|
16
|
+
assert_raises(RuntimeError) do
|
17
|
+
Prop.configure :hello_there, :threshold => 20, :interval => 'hello'
|
18
|
+
end
|
11
19
|
|
12
|
-
|
13
|
-
|
20
|
+
assert_raises(RuntimeError) do
|
21
|
+
Prop.configure :hello_there, :threshold => 'wibble', :interval => 100
|
22
|
+
end
|
14
23
|
end
|
15
24
|
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
end
|
21
|
-
|
22
|
-
assert_raises(RuntimeError) do
|
23
|
-
Prop.configure :hello_there, :threshold => 'wibble', :interval => 100
|
24
|
-
end
|
25
|
+
it "result in a default handle" do
|
26
|
+
Prop.configure :hello_there, :threshold => 4, :interval => 10
|
27
|
+
4.times do |i|
|
28
|
+
assert_equal (i + 1), Prop.throttle!(:hello_there, 'some key')
|
25
29
|
end
|
26
30
|
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
assert_equal (i + 1), Prop.throttle!(:hello_there, 'some key')
|
31
|
-
end
|
32
|
-
|
33
|
-
assert_raises(Prop::RateLimited) { Prop.throttle!(:hello_there, 'some key') }
|
34
|
-
assert_equal 5, Prop.throttle!(:hello_there, 'some key', :threshold => 20)
|
35
|
-
end
|
31
|
+
assert_raises(Prop::RateLimited) { Prop.throttle!(:hello_there, 'some key') }
|
32
|
+
assert_equal 5, Prop.throttle!(:hello_there, 'some key', :threshold => 20)
|
33
|
+
end
|
36
34
|
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
end
|
35
|
+
it "create a handle accepts various cache key types" do
|
36
|
+
Prop.configure :hello_there, :threshold => 4, :interval => 10
|
37
|
+
assert_equal 1, Prop.throttle!(:hello_there, 5)
|
38
|
+
assert_equal 2, Prop.throttle!(:hello_there, 5)
|
39
|
+
assert_equal 1, Prop.throttle!(:hello_there, '6')
|
40
|
+
assert_equal 2, Prop.throttle!(:hello_there, '6')
|
41
|
+
assert_equal 1, Prop.throttle!(:hello_there, [ 5, '6' ])
|
42
|
+
assert_equal 2, Prop.throttle!(:hello_there, [ 5, '6' ])
|
46
43
|
end
|
44
|
+
end
|
47
45
|
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
46
|
+
describe "#disable" do
|
47
|
+
before do
|
48
|
+
Prop.configure :hello, :threshold => 10, :interval => 10
|
49
|
+
end
|
52
50
|
|
53
|
-
|
54
|
-
|
51
|
+
it "not increase the throttle" do
|
52
|
+
assert_equal 1, Prop.throttle!(:hello)
|
53
|
+
assert_equal 2, Prop.throttle!(:hello)
|
54
|
+
Prop.disabled do
|
55
|
+
assert_equal 2, Prop.throttle!(:hello)
|
55
56
|
assert_equal 2, Prop.throttle!(:hello)
|
56
|
-
Prop.disabled
|
57
|
-
assert_equal 2, Prop.throttle!(:hello)
|
58
|
-
assert_equal 2, Prop.throttle!(:hello)
|
59
|
-
assert Prop::Limiter.send(:disabled?)
|
60
|
-
end
|
61
|
-
assert !Prop::Limiter.send(:disabled?)
|
62
|
-
assert_equal 3, Prop.throttle!(:hello)
|
57
|
+
assert Prop::Limiter.send(:disabled?)
|
63
58
|
end
|
59
|
+
assert !Prop::Limiter.send(:disabled?)
|
60
|
+
assert_equal 3, Prop.throttle!(:hello)
|
64
61
|
end
|
62
|
+
end
|
65
63
|
|
66
|
-
|
67
|
-
|
68
|
-
|
64
|
+
describe "#reset" do
|
65
|
+
before do
|
66
|
+
Prop.configure :hello, :threshold => 10, :interval => 10
|
69
67
|
|
70
|
-
|
71
|
-
|
72
|
-
end
|
68
|
+
5.times do |i|
|
69
|
+
assert_equal (i + 1), Prop.throttle!(:hello)
|
73
70
|
end
|
71
|
+
end
|
74
72
|
|
75
|
-
|
76
|
-
|
77
|
-
|
73
|
+
it "set the correct counter to 0" do
|
74
|
+
Prop.throttle!(:hello, 'wibble')
|
75
|
+
Prop.throttle!(:hello, 'wibble')
|
78
76
|
|
79
|
-
|
80
|
-
|
77
|
+
Prop.reset(:hello)
|
78
|
+
assert_equal 1, Prop.throttle!(:hello)
|
81
79
|
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
end
|
80
|
+
assert_equal 3, Prop.throttle!(:hello, 'wibble')
|
81
|
+
Prop.reset(:hello, 'wibble')
|
82
|
+
assert_equal 1, Prop.throttle!(:hello, 'wibble')
|
86
83
|
end
|
84
|
+
end
|
87
85
|
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
end
|
86
|
+
describe "#throttled?" do
|
87
|
+
it "return true once the threshold has been reached" do
|
88
|
+
Prop.configure(:hello, :threshold => 2, :interval => 10)
|
89
|
+
Prop.throttle!(:hello)
|
90
|
+
assert !Prop.throttled?(:hello)
|
91
|
+
Prop.throttle!(:hello)
|
92
|
+
assert Prop.throttled?(:hello)
|
96
93
|
end
|
94
|
+
end
|
97
95
|
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
96
|
+
describe "#count" do
|
97
|
+
before do
|
98
|
+
Prop.configure(:hello, :threshold => 20, :interval => 20)
|
99
|
+
Prop.throttle!(:hello)
|
100
|
+
Prop.throttle!(:hello)
|
101
|
+
end
|
104
102
|
|
105
|
-
|
106
|
-
|
107
|
-
|
103
|
+
it "be aliased by #count" do
|
104
|
+
assert_equal Prop.count(:hello), 2
|
105
|
+
end
|
108
106
|
|
109
|
-
|
110
|
-
|
107
|
+
it "return the number of hits on a throttle" do
|
108
|
+
assert_equal Prop.query(:hello), 2
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
describe "#throttle!" do
|
113
|
+
it "increment counter correctly" do
|
114
|
+
3.times do |i|
|
115
|
+
assert_equal (i + 1), Prop.throttle!(:hello, nil, :threshold => 10, :interval => 10)
|
111
116
|
end
|
112
117
|
end
|
113
118
|
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
assert_equal (i + 1), Prop.throttle!(:hello, nil, :threshold => 10, :interval => 10)
|
118
|
-
end
|
119
|
+
it "reset counter when time window is passed" do
|
120
|
+
3.times do |i|
|
121
|
+
assert_equal (i + 1), Prop.throttle!(:hello, nil, :threshold => 10, :interval => 10)
|
119
122
|
end
|
120
123
|
|
121
|
-
|
122
|
-
3.times do |i|
|
123
|
-
assert_equal (i + 1), Prop.throttle!(:hello, nil, :threshold => 10, :interval => 10)
|
124
|
-
end
|
124
|
+
Time.stubs(:now).returns(@start + 20)
|
125
125
|
|
126
|
-
|
126
|
+
3.times do |i|
|
127
|
+
assert_equal (i + 1), Prop.throttle!(:hello, nil, :threshold => 10, :interval => 10)
|
128
|
+
end
|
129
|
+
end
|
127
130
|
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
+
it "not increment the counter beyond the threshold" do
|
132
|
+
Prop.configure(:hello, :threshold => 5, :interval => 1)
|
133
|
+
10.times do |i|
|
134
|
+
Prop.throttle!(:hello) rescue nil
|
131
135
|
end
|
132
136
|
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
137
|
+
assert_equal 5, Prop.query(:hello)
|
138
|
+
end
|
139
|
+
|
140
|
+
it "support custom increments" do
|
141
|
+
Prop.configure(:hello, :threshold => 100, :interval => 10)
|
138
142
|
|
139
|
-
|
140
|
-
|
143
|
+
Prop.throttle!(:hello)
|
144
|
+
Prop.throttle!(:hello)
|
141
145
|
|
142
|
-
|
143
|
-
Prop.configure(:hello, :threshold => 100, :interval => 10)
|
146
|
+
assert_equal 2, Prop.query(:hello)
|
144
147
|
|
145
|
-
|
146
|
-
Prop.throttle!(:hello)
|
148
|
+
Prop.throttle!(:hello, nil, :increment => 48)
|
147
149
|
|
148
|
-
|
150
|
+
assert_equal 50, Prop.query(:hello)
|
151
|
+
end
|
149
152
|
|
150
|
-
|
153
|
+
it "raise Prop::RateLimited when the threshold is exceeded" do
|
154
|
+
Prop.configure(:hello, :threshold => 5, :interval => 10, :description => "Boom!")
|
151
155
|
|
152
|
-
|
156
|
+
5.times do |i|
|
157
|
+
Prop.throttle!(:hello, nil)
|
153
158
|
end
|
154
|
-
|
155
|
-
|
156
|
-
Prop.configure(:hello, :threshold => 5, :interval => 10, :description => "Boom!")
|
157
|
-
|
158
|
-
5.times do |i|
|
159
|
-
Prop.throttle!(:hello, nil)
|
160
|
-
end
|
161
|
-
assert_raises(Prop::RateLimited) do
|
162
|
-
Prop.throttle!(:hello, nil)
|
163
|
-
end
|
164
|
-
|
165
|
-
begin
|
166
|
-
Prop.throttle!(:hello, nil)
|
167
|
-
fail
|
168
|
-
rescue Prop::RateLimited => e
|
169
|
-
assert_equal :hello, e.handle
|
170
|
-
assert_match "5 tries per 10s exceeded for key", e.message
|
171
|
-
assert_equal "Boom!", e.description
|
172
|
-
assert e.retry_after
|
173
|
-
end
|
159
|
+
assert_raises(Prop::RateLimited) do
|
160
|
+
Prop.throttle!(:hello, nil)
|
174
161
|
end
|
175
162
|
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
163
|
+
begin
|
164
|
+
Prop.throttle!(:hello, nil)
|
165
|
+
fail
|
166
|
+
rescue Prop::RateLimited => e
|
167
|
+
assert_equal :hello, e.handle
|
168
|
+
assert_match "5 tries per 10s exceeded for key", e.message
|
169
|
+
assert_equal "Boom!", e.description
|
170
|
+
assert e.retry_after
|
180
171
|
end
|
181
172
|
end
|
182
173
|
|
183
|
-
|
184
|
-
|
185
|
-
Prop.
|
186
|
-
Prop.configure(:login_attempts, :threshold => 10, :interval => 30)
|
174
|
+
it "raise a RuntimeError when a handle has not been configured" do
|
175
|
+
assert_raises(RuntimeError) do
|
176
|
+
Prop.throttle!(:no_such_handle, nil, :threshold => 5, :interval => 10)
|
187
177
|
end
|
178
|
+
end
|
179
|
+
end
|
188
180
|
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
assert_equal(0, Prop.count(:login_attempts, user_id))
|
194
|
-
end
|
181
|
+
describe 'different handles with the same interval' do
|
182
|
+
before do
|
183
|
+
Prop.configure(:api_requests, :threshold => 100, :interval => 30)
|
184
|
+
Prop.configure(:login_attempts, :threshold => 10, :interval => 30)
|
195
185
|
end
|
196
186
|
|
187
|
+
it 'be counted separately' do
|
188
|
+
user_id = 42
|
189
|
+
Prop.throttle!(:api_requests, user_id)
|
190
|
+
assert_equal(1, Prop.count(:api_requests, user_id))
|
191
|
+
assert_equal(0, Prop.count(:login_attempts, user_id))
|
192
|
+
end
|
197
193
|
end
|
194
|
+
|
198
195
|
end
|
data/test/test_rate_limited.rb
CHANGED
@@ -1,27 +1,24 @@
|
|
1
1
|
require 'helper'
|
2
2
|
|
3
|
-
|
3
|
+
describe Prop::RateLimited do
|
4
|
+
describe "#initialize" do
|
5
|
+
before do
|
6
|
+
time = Time.at(1333685680)
|
7
|
+
Time.stubs(:now).returns(time)
|
4
8
|
|
5
|
-
|
6
|
-
|
7
|
-
setup do
|
8
|
-
time = Time.at(1333685680)
|
9
|
-
Time.stubs(:now).returns(time)
|
10
|
-
|
11
|
-
@error = Prop::RateLimited.new(:handle => "foo", :threshold => 10, :interval => 60, :cache_key => "wibble", :description => "Boom!")
|
12
|
-
end
|
9
|
+
@error = Prop::RateLimited.new(:handle => "foo", :threshold => 10, :interval => 60, :cache_key => "wibble", :description => "Boom!")
|
10
|
+
end
|
13
11
|
|
14
|
-
|
15
|
-
|
16
|
-
|
12
|
+
it "return an error instance" do
|
13
|
+
assert @error.is_a?(StandardError)
|
14
|
+
assert @error.is_a?(Prop::RateLimited)
|
17
15
|
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
end
|
16
|
+
assert_equal "foo", @error.handle
|
17
|
+
assert_equal "wibble", @error.cache_key
|
18
|
+
assert_equal "Boom!", @error.description
|
19
|
+
assert_equal "foo threshold of 10 tries per 60s exceeded for key 'nil', hash wibble", @error.message
|
20
|
+
assert_equal 20, @error.retry_after
|
24
21
|
end
|
25
|
-
|
26
22
|
end
|
27
|
-
|
23
|
+
|
24
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: prop
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.0.
|
4
|
+
version: 1.0.1
|
5
5
|
prerelease:
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
@@ -44,7 +44,7 @@ dependencies:
|
|
44
44
|
- !ruby/object:Gem::Version
|
45
45
|
version: '0'
|
46
46
|
- !ruby/object:Gem::Dependency
|
47
|
-
name:
|
47
|
+
name: minitest
|
48
48
|
requirement: !ruby/object:Gem::Requirement
|
49
49
|
none: false
|
50
50
|
requirements:
|