prop 0.6.6 → 0.7.0
Sign up to get free protection for your applications and to get access to all the features.
- data/README.md +6 -18
- data/lib/prop.rb +8 -119
- data/lib/prop/key.rb +28 -0
- data/lib/prop/limiter.rb +116 -0
- data/lib/prop/middleware.rb +23 -0
- data/lib/prop/options.rb +24 -0
- data/lib/prop/rate_limited.rb +19 -0
- data/prop.gemspec +11 -2
- data/test/test_key.rb +26 -0
- data/test/test_middleware.rb +37 -0
- data/test/test_options.rb +44 -0
- data/test/test_prop.rb +8 -8
- data/test/test_rate_limited.rb +27 -0
- metadata +18 -5
data/README.md
CHANGED
@@ -3,6 +3,8 @@
|
|
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 and 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.
|
7
|
+
|
6
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:
|
7
9
|
|
8
10
|
Prop.read do |key|
|
@@ -13,7 +15,7 @@ To get going with Prop you first define the read and write operations. These def
|
|
13
15
|
Rails.cache.write(key, value)
|
14
16
|
end
|
15
17
|
|
16
|
-
You can choose to rely on
|
18
|
+
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.
|
17
19
|
|
18
20
|
## Defining thresholds
|
19
21
|
|
@@ -21,7 +23,7 @@ Once the read and write operations are defined, you can optionally define thresh
|
|
21
23
|
|
22
24
|
Prop.configure(:mails_per_hour, :threshold => 100, :interval => 1.hour, :description => "Mail rate limit exceeded")
|
23
25
|
|
24
|
-
You can now put the throttle to work with this values, by passing the
|
26
|
+
The `:mails_per_hour` in the above is called the "handle". You can now put the throttle to work with this values, by passing the handle to the respective methods in Prop:
|
25
27
|
|
26
28
|
# Throws Prop::RateLimitExceededError if the threshold/interval has been reached
|
27
29
|
Prop.throttle!(:mails_per_hour)
|
@@ -33,7 +35,7 @@ You can now put the throttle to work with this values, by passing the "handle" t
|
|
33
35
|
Prop.reset(:mails_per_hour)
|
34
36
|
|
35
37
|
# Returns the value of this throttle, usually a count, but see below for more
|
36
|
-
Prop.
|
38
|
+
Prop.count(:mails_per_hour)
|
37
39
|
|
38
40
|
Prop will raise a RuntimeError if you attempt to operate on an undefined handle.
|
39
41
|
|
@@ -52,7 +54,7 @@ The throttle scope can also be an array of values, e.g.:
|
|
52
54
|
|
53
55
|
## Error handling
|
54
56
|
|
55
|
-
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::
|
57
|
+
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:
|
56
58
|
|
57
59
|
rescue_from Prop::RateLimitExceededError do |e|
|
58
60
|
if e.handle == :authorization_attempt
|
@@ -86,17 +88,3 @@ The default (and smallest possible) increment is 1, you can set that to any inte
|
|
86
88
|
Prop.setup(:execute_time, :threshold => 10, :interval => 1.minute)
|
87
89
|
Prop.throttle!(:execute_time, account.id, :increment => (Benchmark.realtime { execute }).to_i)
|
88
90
|
|
89
|
-
## How it works
|
90
|
-
|
91
|
-
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.
|
92
|
-
|
93
|
-
## Note on Patches/Pull Requests
|
94
|
-
|
95
|
-
* Fork the project.
|
96
|
-
* Make your feature addition or bug fix.
|
97
|
-
* Add tests for it. This is important so I don't break it in a
|
98
|
-
future version unintentionally.
|
99
|
-
* Commit, do not mess with rakefile, version, or history.
|
100
|
-
(if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
|
101
|
-
* Send me a pull request. Bonus points for topic branches.
|
102
|
-
|
data/lib/prop.rb
CHANGED
@@ -1,124 +1,13 @@
|
|
1
|
-
require
|
1
|
+
require "prop/limiter"
|
2
|
+
require "forwardable"
|
2
3
|
|
3
|
-
|
4
|
-
|
5
|
-
(class << self; self; end).instance_eval { define_method(name, &blk) }
|
6
|
-
end
|
7
|
-
end
|
8
|
-
|
9
|
-
class Prop
|
10
|
-
VERSION = "0.6.6"
|
11
|
-
|
12
|
-
class RateLimitExceededError < RuntimeError
|
13
|
-
attr_accessor :handle, :retry_after, :description
|
14
|
-
|
15
|
-
def self.create(handle, key, threshold, description = nil)
|
16
|
-
error = new("#{handle} threshold of #{threshold} exceeded for key '#{key}'")
|
17
|
-
error.description = description
|
18
|
-
error.handle = handle
|
19
|
-
error.retry_after = threshold - Time.now.to_i % threshold if threshold > 0
|
20
|
-
|
21
|
-
raise error
|
22
|
-
end
|
23
|
-
end
|
4
|
+
module Prop
|
5
|
+
VERSION = "0.7.0"
|
24
6
|
|
7
|
+
# Short hand for accessing Prop::Limiter methods
|
25
8
|
class << self
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
self.reader = blk
|
30
|
-
end
|
31
|
-
|
32
|
-
def write(&blk)
|
33
|
-
self.writer = blk
|
34
|
-
end
|
35
|
-
|
36
|
-
def configure(handle, defaults)
|
37
|
-
raise RuntimeError.new("Invalid threshold setting") unless defaults[:threshold].to_i > 0
|
38
|
-
raise RuntimeError.new("Invalid interval setting") unless defaults[:interval].to_i > 0
|
39
|
-
|
40
|
-
self.handles ||= {}
|
41
|
-
self.handles[handle] = defaults
|
42
|
-
end
|
43
|
-
|
44
|
-
def disabled(&block)
|
45
|
-
@disabled = true
|
46
|
-
yield
|
47
|
-
ensure
|
48
|
-
@disabled = false
|
49
|
-
end
|
50
|
-
|
51
|
-
def disabled?
|
52
|
-
!!@disabled
|
53
|
-
end
|
54
|
-
|
55
|
-
def throttle!(handle, key = nil, options = {})
|
56
|
-
options = sanitized_prop_options(handle, key, options)
|
57
|
-
cache_key = sanitized_prop_key(handle, key, options)
|
58
|
-
counter = reader.call(cache_key).to_i
|
59
|
-
|
60
|
-
return counter if disabled?
|
61
|
-
|
62
|
-
if counter >= options[:threshold]
|
63
|
-
raise Prop::RateLimitExceededError.create(handle, normalize_cache_key(key), options[:threshold], options[:description])
|
64
|
-
else
|
65
|
-
writer.call(cache_key, counter + [ 1, options[:increment].to_i ].max)
|
66
|
-
end
|
67
|
-
end
|
68
|
-
|
69
|
-
def throttled?(handle, key = nil, options = {})
|
70
|
-
options = sanitized_prop_options(handle, key, options)
|
71
|
-
cache_key = sanitized_prop_key(handle, key, options)
|
72
|
-
|
73
|
-
reader.call(cache_key).to_i >= options[:threshold]
|
74
|
-
end
|
75
|
-
|
76
|
-
def reset(handle, key = nil, options = {})
|
77
|
-
options = sanitized_prop_options(handle, key, options)
|
78
|
-
cache_key = sanitized_prop_key(handle, key, options)
|
79
|
-
|
80
|
-
writer.call(cache_key, 0)
|
81
|
-
end
|
82
|
-
|
83
|
-
def query(handle, key = nil, options = {})
|
84
|
-
options = sanitized_prop_options(handle, key, options)
|
85
|
-
cache_key = sanitized_prop_key(handle, key, options)
|
86
|
-
|
87
|
-
reader.call(cache_key).to_i
|
88
|
-
end
|
89
|
-
alias :count :query
|
90
|
-
|
91
|
-
private
|
92
|
-
|
93
|
-
# Builds the expiring cache key
|
94
|
-
def sanitized_prop_key(handle, key, options)
|
95
|
-
window = (Time.now.to_i / options[:interval])
|
96
|
-
cache_key = normalize_cache_key([handle, key, window])
|
97
|
-
"prop/#{Digest::MD5.hexdigest(cache_key)}"
|
98
|
-
end
|
99
|
-
|
100
|
-
# Sanitizes the option set and sets defaults
|
101
|
-
def sanitized_prop_options(handle, key, options)
|
102
|
-
raise RuntimeError.new("No such handle configured: #{handle.inspect}") if handles.nil? || handles[handle].nil?
|
103
|
-
|
104
|
-
defaults = handles[handle]
|
105
|
-
return {
|
106
|
-
:key => normalize_cache_key(key),
|
107
|
-
:increment => defaults[:increment],
|
108
|
-
:description => defaults[:description],
|
109
|
-
:threshold => defaults[:threshold].to_i,
|
110
|
-
:interval => defaults[:interval].to_i
|
111
|
-
}.merge(options)
|
112
|
-
end
|
113
|
-
|
114
|
-
# Simple key expansion only supports arrays and primitives
|
115
|
-
def normalize_cache_key(key)
|
116
|
-
if key.is_a?(Array)
|
117
|
-
key.map { |part| normalize_cache_key(part) }.join('/')
|
118
|
-
else
|
119
|
-
key.to_s
|
120
|
-
end
|
121
|
-
end
|
122
|
-
|
9
|
+
extend Forwardable
|
10
|
+
def_delegators :"Prop::Limiter", :read, :write, :configure, :disabled
|
11
|
+
def_delegators :"Prop::Limiter", :throttle!, :throttled?, :count, :query, :reset
|
123
12
|
end
|
124
13
|
end
|
data/lib/prop/key.rb
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
require "digest/md5"
|
2
|
+
|
3
|
+
module Prop
|
4
|
+
class Key
|
5
|
+
|
6
|
+
# Builds the expiring cache key
|
7
|
+
def self.build(options)
|
8
|
+
key = options.fetch(:key)
|
9
|
+
handle = options.fetch(:handle)
|
10
|
+
interval = options.fetch(:interval)
|
11
|
+
|
12
|
+
window = (Time.now.to_i / interval)
|
13
|
+
cache_key = normalize([ handle, key, window ])
|
14
|
+
|
15
|
+
"prop/#{Digest::MD5.hexdigest(cache_key)}"
|
16
|
+
end
|
17
|
+
|
18
|
+
# Simple key expansion only supports arrays and primitives
|
19
|
+
def self.normalize(key)
|
20
|
+
if key.is_a?(Array)
|
21
|
+
key.flatten.join("/")
|
22
|
+
else
|
23
|
+
key.to_s
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
28
|
+
end
|
data/lib/prop/limiter.rb
ADDED
@@ -0,0 +1,116 @@
|
|
1
|
+
require 'prop/rate_limited'
|
2
|
+
require 'prop/key'
|
3
|
+
require 'prop/options'
|
4
|
+
|
5
|
+
module Prop
|
6
|
+
class Limiter
|
7
|
+
|
8
|
+
class << self
|
9
|
+
attr_accessor :handles, :reader, :writer
|
10
|
+
|
11
|
+
def read(&blk)
|
12
|
+
self.reader = blk
|
13
|
+
end
|
14
|
+
|
15
|
+
def write(&blk)
|
16
|
+
self.writer = blk
|
17
|
+
end
|
18
|
+
|
19
|
+
# Public: Registers a handle for rate limiting
|
20
|
+
#
|
21
|
+
# handle - the name of the handle you wish to use in your code, e.g. :login_attempt
|
22
|
+
# defaults - the settings for this handle, e.g. { :threshold => 5, :interval => 5.minutes }
|
23
|
+
#
|
24
|
+
# Raises Prop::RateLimited if the number if the threshold for this handle has been reached
|
25
|
+
def configure(handle, defaults)
|
26
|
+
raise RuntimeError.new("Invalid threshold setting") unless defaults[:threshold].to_i > 0
|
27
|
+
raise RuntimeError.new("Invalid interval setting") unless defaults[:interval].to_i > 0
|
28
|
+
|
29
|
+
self.handles ||= {}
|
30
|
+
self.handles[handle] = defaults
|
31
|
+
end
|
32
|
+
|
33
|
+
# Public: Disables Prop for a block of code
|
34
|
+
#
|
35
|
+
# block - a block of code within which Prop will not raise
|
36
|
+
def disabled(&block)
|
37
|
+
@disabled = true
|
38
|
+
yield
|
39
|
+
ensure
|
40
|
+
@disabled = false
|
41
|
+
end
|
42
|
+
|
43
|
+
# Public: Records a single action for the given handle/key combination.
|
44
|
+
#
|
45
|
+
# handle - the registered handle associated with the action
|
46
|
+
# key - a custom request specific key, e.g. [ account.id, "download", request.remote_ip ]
|
47
|
+
# options - request specific overrides to the defaults configured for this handle
|
48
|
+
#
|
49
|
+
# Raises Prop::RateLimited if the number if the threshold for this handle has been reached
|
50
|
+
def throttle!(handle, key = nil, options = {})
|
51
|
+
options, cache_key = prepare(handle, key, options)
|
52
|
+
|
53
|
+
counter = reader.call(cache_key).to_i
|
54
|
+
|
55
|
+
return counter if disabled?
|
56
|
+
|
57
|
+
if counter >= options[:threshold]
|
58
|
+
raise Prop::RateLimited.new(options.merge(:cache_key => cache_key, :handle => handle))
|
59
|
+
else
|
60
|
+
writer.call(cache_key, counter + [ 1, options[:increment].to_i ].max)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Public: Allows to query whether the given handle/key combination is currently throttled
|
65
|
+
#
|
66
|
+
# handle - the throttle identifier
|
67
|
+
# key - the associated key
|
68
|
+
#
|
69
|
+
# Returns true if a call to `throttle!` with same parameters would raise, otherwise false
|
70
|
+
def throttled?(handle, key = nil, options = {})
|
71
|
+
options, cache_key = prepare(handle, key, options)
|
72
|
+
reader.call(cache_key).to_i >= options[:threshold]
|
73
|
+
end
|
74
|
+
|
75
|
+
# Public: Resets a specific throttle
|
76
|
+
#
|
77
|
+
# handle - the throttle identifier
|
78
|
+
# key - the associated key
|
79
|
+
#
|
80
|
+
# Returns nothing
|
81
|
+
def reset(handle, key = nil, options = {})
|
82
|
+
options, cache_key = prepare(handle, key, options)
|
83
|
+
writer.call(cache_key, 0)
|
84
|
+
end
|
85
|
+
|
86
|
+
# Public: Counts the number of times the given handle/key combination has been hit in the current window
|
87
|
+
#
|
88
|
+
# handle - the throttle identifier
|
89
|
+
# key - the associated key
|
90
|
+
#
|
91
|
+
# Returns a count of hits in the current window
|
92
|
+
def count(handle, key = nil, options = {})
|
93
|
+
options, cache_key = prepare(handle, key, options)
|
94
|
+
reader.call(cache_key).to_i
|
95
|
+
end
|
96
|
+
alias :query :count
|
97
|
+
|
98
|
+
private
|
99
|
+
|
100
|
+
def disabled?
|
101
|
+
!!@disabled
|
102
|
+
end
|
103
|
+
|
104
|
+
def prepare(handle, key, params)
|
105
|
+
raise RuntimeError.new("No such handle configured: #{handle.inspect}") unless (handles || {}).key?(handle)
|
106
|
+
|
107
|
+
defaults = handles[handle]
|
108
|
+
options = Prop::Options.build(:key => key, :params => params, :defaults => defaults)
|
109
|
+
cache_key = Prop::Key.build(:key => key, :handle => handle, :interval => options[:interval])
|
110
|
+
|
111
|
+
[ options, cache_key ]
|
112
|
+
end
|
113
|
+
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Prop
|
2
|
+
# Convenience middleware that conveys the message configured on a Prop handle as well
|
3
|
+
# as time left before the current window has passed in a Retry-After header.
|
4
|
+
class Middleware
|
5
|
+
|
6
|
+
def initialize(app)
|
7
|
+
@app = app
|
8
|
+
end
|
9
|
+
|
10
|
+
def call(env, options = {})
|
11
|
+
begin
|
12
|
+
@app.call(env)
|
13
|
+
rescue Prop::RateLimited => e
|
14
|
+
body = e.description || "This action has been rate limited"
|
15
|
+
|
16
|
+
headers = { "Content-Type" => "text/plain", "Content-Length" => body.size }
|
17
|
+
headers["Retry-After"] = e.retry_after if e.retry_after > 0
|
18
|
+
|
19
|
+
[ 429, headers, [ body ]]
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
data/lib/prop/options.rb
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
require 'prop/key'
|
2
|
+
|
3
|
+
module Prop
|
4
|
+
class Options
|
5
|
+
|
6
|
+
# Sanitizes the option set and sets defaults
|
7
|
+
def self.build(options)
|
8
|
+
key = options.fetch(:key)
|
9
|
+
params = options.fetch(:params)
|
10
|
+
defaults = options.fetch(:defaults)
|
11
|
+
result = defaults.merge(params)
|
12
|
+
|
13
|
+
result[:key] = Prop::Key.normalize(key)
|
14
|
+
result[:threshold] = result[:threshold].to_i
|
15
|
+
result[:interval] = result[:interval].to_i
|
16
|
+
|
17
|
+
raise RuntimeError.new("Invalid threshold setting") unless result[:threshold] > 0
|
18
|
+
raise RuntimeError.new("Invalid interval setting") unless result[:interval] > 0
|
19
|
+
|
20
|
+
result
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Prop
|
2
|
+
class RateLimited < StandardError
|
3
|
+
attr_accessor :handle, :cache_key, :retry_after, :description
|
4
|
+
|
5
|
+
def initialize(options)
|
6
|
+
handle = options.fetch(:handle)
|
7
|
+
cache_key = options.fetch(:cache_key)
|
8
|
+
interval = options.fetch(:interval).to_i
|
9
|
+
threshold = options.fetch(:threshold).to_i
|
10
|
+
|
11
|
+
super("#{handle} threshold of #{threshold}/#{interval}s exceeded for key '#{cache_key}'")
|
12
|
+
|
13
|
+
self.description = options[:description]
|
14
|
+
self.handle = handle
|
15
|
+
self.cache_key = cache_key
|
16
|
+
self.retry_after = interval - Time.now.to_i % interval
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
data/prop.gemspec
CHANGED
@@ -13,8 +13,8 @@ Gem::Specification.new do |s|
|
|
13
13
|
## If your rubyforge_project name is different, then edit it and comment out
|
14
14
|
## the sub! line in the Rakefile
|
15
15
|
s.name = 'prop'
|
16
|
-
s.version = '0.
|
17
|
-
s.date = '2012-
|
16
|
+
s.version = '0.7.0'
|
17
|
+
s.date = '2012-04-06'
|
18
18
|
s.rubyforge_project = 'prop'
|
19
19
|
|
20
20
|
## Make sure your summary is short. The description may be as long
|
@@ -66,9 +66,18 @@ Gem::Specification.new do |s|
|
|
66
66
|
README.md
|
67
67
|
Rakefile
|
68
68
|
lib/prop.rb
|
69
|
+
lib/prop/key.rb
|
70
|
+
lib/prop/limiter.rb
|
71
|
+
lib/prop/middleware.rb
|
72
|
+
lib/prop/options.rb
|
73
|
+
lib/prop/rate_limited.rb
|
69
74
|
prop.gemspec
|
70
75
|
test/helper.rb
|
76
|
+
test/test_key.rb
|
77
|
+
test/test_middleware.rb
|
78
|
+
test/test_options.rb
|
71
79
|
test/test_prop.rb
|
80
|
+
test/test_rate_limited.rb
|
72
81
|
]
|
73
82
|
# = MANIFEST =
|
74
83
|
|
data/test/test_key.rb
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
require 'helper'
|
2
|
+
|
3
|
+
class TestKey < Test::Unit::TestCase
|
4
|
+
|
5
|
+
context Prop::Key do
|
6
|
+
context "#build" do
|
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
|
10
|
+
end
|
11
|
+
|
12
|
+
context "#normalize" do
|
13
|
+
should "turn a Fixnum into a String" do
|
14
|
+
assert_equal "3", Prop::Key.normalize(3)
|
15
|
+
end
|
16
|
+
|
17
|
+
should "return a String" do
|
18
|
+
assert_equal "S", Prop::Key.normalize("S")
|
19
|
+
end
|
20
|
+
|
21
|
+
should "flatten and join an Array" do
|
22
|
+
assert_equal "1/B/3", Prop::Key.normalize([ 1, "B", "3" ])
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'helper'
|
2
|
+
require 'prop/middleware'
|
3
|
+
require 'prop/rate_limited'
|
4
|
+
|
5
|
+
class TestMiddleware < Test::Unit::TestCase
|
6
|
+
|
7
|
+
context Prop::Middleware do
|
8
|
+
setup do
|
9
|
+
@app = stub()
|
10
|
+
@env = {}
|
11
|
+
@middleware = Prop::Middleware.new(@app)
|
12
|
+
end
|
13
|
+
|
14
|
+
context "when the app call completes" do
|
15
|
+
setup do
|
16
|
+
@app.expects(:call).with(@env).returns("response")
|
17
|
+
end
|
18
|
+
|
19
|
+
should "return the response" do
|
20
|
+
assert_equal "response", @middleware.call(@env)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
context "when the app call results in a raised throttle" do
|
25
|
+
setup do
|
26
|
+
@app.expects(:call).with(@env).raises(Prop::RateLimited.new(:handle => "foo", :threshold => 10, :interval => 60, :cache_key => "wibble", :description => "Boom!"))
|
27
|
+
end
|
28
|
+
|
29
|
+
should "return the rate limited message" do
|
30
|
+
response = @middleware.call(@env)
|
31
|
+
|
32
|
+
assert_equal 429, response[0]
|
33
|
+
assert_equal ["Boom!"], response[2]
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'helper'
|
2
|
+
|
3
|
+
class TestOptions < Test::Unit::TestCase
|
4
|
+
|
5
|
+
context Prop::Options do
|
6
|
+
context "#build" do
|
7
|
+
setup do
|
8
|
+
@args = { :key => "hello", :params => { :foo => "bif" }, :defaults => { :foo => "bar", :baz => "moo", :threshold => 10, :interval => 5 }}
|
9
|
+
end
|
10
|
+
|
11
|
+
context "when given valid input" do
|
12
|
+
setup do
|
13
|
+
@options = Prop::Options.build(@args)
|
14
|
+
end
|
15
|
+
|
16
|
+
should "support defaults" do
|
17
|
+
assert_equal "moo", @options[:baz]
|
18
|
+
end
|
19
|
+
|
20
|
+
should "override defaults" do
|
21
|
+
assert_equal "bif", @options[:foo]
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
context "when given invalid input" do
|
26
|
+
should "raise when not given an interval" do
|
27
|
+
@args[:defaults].delete(:interval)
|
28
|
+
assert_raises(RuntimeError) { Prop::Options.build(@args) }
|
29
|
+
end
|
30
|
+
|
31
|
+
should "raise when not given a threshold" do
|
32
|
+
@args[:defaults].delete(:threshold)
|
33
|
+
assert_raises(RuntimeError) { Prop::Options.build(@args) }
|
34
|
+
end
|
35
|
+
|
36
|
+
should "raise when not given a key" do
|
37
|
+
@args.delete(:key)
|
38
|
+
assert_raises(IndexError) { Prop::Options.build(@args) }
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
end
|
44
|
+
end
|
data/test/test_prop.rb
CHANGED
@@ -29,7 +29,7 @@ class TestProp < Test::Unit::TestCase
|
|
29
29
|
assert_equal (i + 1), Prop.throttle!(:hello_there, 'some key')
|
30
30
|
end
|
31
31
|
|
32
|
-
assert_raises(Prop::
|
32
|
+
assert_raises(Prop::RateLimited) { Prop.throttle!(:hello_there, 'some key') }
|
33
33
|
assert_equal 5, Prop.throttle!(:hello_there, 'some key', :threshold => 20)
|
34
34
|
end
|
35
35
|
|
@@ -55,9 +55,9 @@ class TestProp < Test::Unit::TestCase
|
|
55
55
|
Prop.disabled do
|
56
56
|
assert_equal 2, Prop.throttle!(:hello)
|
57
57
|
assert_equal 2, Prop.throttle!(:hello)
|
58
|
-
assert Prop.disabled?
|
58
|
+
assert Prop::Limiter.send(:disabled?)
|
59
59
|
end
|
60
|
-
assert !Prop.disabled?
|
60
|
+
assert !Prop::Limiter.send(:disabled?)
|
61
61
|
assert_equal 3, Prop.throttle!(:hello)
|
62
62
|
end
|
63
63
|
end
|
@@ -94,7 +94,7 @@ class TestProp < Test::Unit::TestCase
|
|
94
94
|
end
|
95
95
|
end
|
96
96
|
|
97
|
-
context "#
|
97
|
+
context "#count" do
|
98
98
|
setup do
|
99
99
|
Prop.configure(:hello, :threshold => 20, :interval => 20)
|
100
100
|
Prop.throttle!(:hello)
|
@@ -151,22 +151,22 @@ class TestProp < Test::Unit::TestCase
|
|
151
151
|
assert_equal 50, Prop.query(:hello)
|
152
152
|
end
|
153
153
|
|
154
|
-
should "raise Prop::
|
154
|
+
should "raise Prop::RateLimited when the threshold is exceeded" do
|
155
155
|
Prop.configure(:hello, :threshold => 5, :interval => 10, :description => "Boom!")
|
156
156
|
|
157
157
|
5.times do |i|
|
158
158
|
Prop.throttle!(:hello, nil)
|
159
159
|
end
|
160
|
-
assert_raises(Prop::
|
160
|
+
assert_raises(Prop::RateLimited) do
|
161
161
|
Prop.throttle!(:hello, nil)
|
162
162
|
end
|
163
163
|
|
164
164
|
begin
|
165
165
|
Prop.throttle!(:hello, nil)
|
166
166
|
fail
|
167
|
-
rescue Prop::
|
167
|
+
rescue Prop::RateLimited => e
|
168
168
|
assert_equal :hello, e.handle
|
169
|
-
|
169
|
+
assert_match "hello threshold of 5/10s exceeded for key", e.message
|
170
170
|
assert_equal "Boom!", e.description
|
171
171
|
assert e.retry_after
|
172
172
|
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'helper'
|
2
|
+
|
3
|
+
class TestRateLimited < Test::Unit::TestCase
|
4
|
+
|
5
|
+
context Prop::RateLimited do
|
6
|
+
context "#initialize" do
|
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
|
13
|
+
|
14
|
+
should "return an error instance" do
|
15
|
+
assert @error.is_a?(StandardError)
|
16
|
+
assert @error.is_a?(Prop::RateLimited)
|
17
|
+
|
18
|
+
assert_equal "foo", @error.handle
|
19
|
+
assert_equal "wibble", @error.cache_key
|
20
|
+
assert_equal "Boom!", @error.description
|
21
|
+
assert_equal "foo threshold of 10/60s exceeded for key 'wibble'", @error.message
|
22
|
+
assert_equal 20, @error.retry_after
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
27
|
+
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: 3
|
5
5
|
prerelease:
|
6
6
|
segments:
|
7
7
|
- 0
|
8
|
-
-
|
9
|
-
-
|
10
|
-
version: 0.
|
8
|
+
- 7
|
9
|
+
- 0
|
10
|
+
version: 0.7.0
|
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: 2012-
|
18
|
+
date: 2012-04-06 00:00:00 Z
|
19
19
|
dependencies:
|
20
20
|
- !ruby/object:Gem::Dependency
|
21
21
|
name: rake
|
@@ -88,9 +88,18 @@ files:
|
|
88
88
|
- README.md
|
89
89
|
- Rakefile
|
90
90
|
- lib/prop.rb
|
91
|
+
- lib/prop/key.rb
|
92
|
+
- lib/prop/limiter.rb
|
93
|
+
- lib/prop/middleware.rb
|
94
|
+
- lib/prop/options.rb
|
95
|
+
- lib/prop/rate_limited.rb
|
91
96
|
- prop.gemspec
|
92
97
|
- test/helper.rb
|
98
|
+
- test/test_key.rb
|
99
|
+
- test/test_middleware.rb
|
100
|
+
- test/test_options.rb
|
93
101
|
- test/test_prop.rb
|
102
|
+
- test/test_rate_limited.rb
|
94
103
|
homepage: http://github.com/morten/prop
|
95
104
|
licenses: []
|
96
105
|
|
@@ -125,5 +134,9 @@ signing_key:
|
|
125
134
|
specification_version: 2
|
126
135
|
summary: Gem for implementing rate limits.
|
127
136
|
test_files:
|
137
|
+
- test/test_key.rb
|
138
|
+
- test/test_middleware.rb
|
139
|
+
- test/test_options.rb
|
128
140
|
- test/test_prop.rb
|
141
|
+
- test/test_rate_limited.rb
|
129
142
|
has_rdoc:
|