improved-rack-throttle-w-expiry 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/.document +5 -0
- data/Gemfile +16 -0
- data/Gemfile.lock +57 -0
- data/README.md +247 -0
- data/ROADMAP.md +14 -0
- data/Rakefile +43 -0
- data/UNLICENSE +24 -0
- data/doc/.gitignore +2 -0
- data/etc/gdbm.ru +7 -0
- data/etc/hash.ru +6 -0
- data/etc/memcache-client.ru +8 -0
- data/etc/memcache.ru +8 -0
- data/etc/memcached.ru +8 -0
- data/etc/redis.ru +8 -0
- data/improved-rack-throttle-w-expiry.gemspec +94 -0
- data/lib/rack/throttle.rb +17 -0
- data/lib/rack/throttle/limiters/daily.rb +49 -0
- data/lib/rack/throttle/limiters/hourly.rb +49 -0
- data/lib/rack/throttle/limiters/interval.rb +63 -0
- data/lib/rack/throttle/limiters/limiter.rb +231 -0
- data/lib/rack/throttle/limiters/sliding_window.rb +86 -0
- data/lib/rack/throttle/limiters/time_window.rb +21 -0
- data/lib/rack/throttle/matchers/matcher.rb +32 -0
- data/lib/rack/throttle/matchers/method_matcher.rb +24 -0
- data/lib/rack/throttle/matchers/url_matcher.rb +24 -0
- data/lib/rack/throttle/matchers/user_agent_matcher.rb +23 -0
- data/lib/rack/throttle/version.rb +23 -0
- data/spec/limiters/daily_spec.rb +31 -0
- data/spec/limiters/hourly_spec.rb +32 -0
- data/spec/limiters/interval_spec.rb +45 -0
- data/spec/limiters/limiter_spec.rb +51 -0
- data/spec/limiters/sliding_window_spec.rb +67 -0
- data/spec/matchers/method_matcher_spec.rb +27 -0
- data/spec/matchers/url_matcher_spec.rb +28 -0
- data/spec/matchers/user_agent_matcher_spec.rb +28 -0
- data/spec/spec_helper.rb +51 -0
- metadata +215 -0
@@ -0,0 +1,86 @@
|
|
1
|
+
module Rack; module Throttle
|
2
|
+
##
|
3
|
+
# This rate limiter strategy throttles the application with
|
4
|
+
# a sliding window (implemented as a leaky bucket). It operates
|
5
|
+
# on second-level resolution. It takes :burst and :average
|
6
|
+
# options, which correspond to the maximum size of a traffic
|
7
|
+
# burst, and the maximum allowed average traffic level.
|
8
|
+
class SlidingWindow < Limiter
|
9
|
+
##
|
10
|
+
# @param [#call] app
|
11
|
+
# @param [Hash{Symbol => Object}] options
|
12
|
+
# @option options [Integer] :burst 5
|
13
|
+
# @option options [Float] :average 1
|
14
|
+
def initialize(app, options = {})
|
15
|
+
super
|
16
|
+
options[:burst] ||= 5
|
17
|
+
options[:average] ||= 1
|
18
|
+
end
|
19
|
+
|
20
|
+
##
|
21
|
+
# Returns `true` if the request conforms to the
|
22
|
+
# specified :average and :burst rules
|
23
|
+
#
|
24
|
+
# @param [Rack::Request] request
|
25
|
+
# @return [Boolean]
|
26
|
+
def allowed?(request)
|
27
|
+
t1 = request_start_time(request)
|
28
|
+
key = cache_key(request)
|
29
|
+
bucket = cache_get(key) rescue nil
|
30
|
+
bucket ||= LeakyBucket.new(options[:burst], options[:average])
|
31
|
+
bucket.maximum, bucket.outflow = options[:burst], options[:average]
|
32
|
+
bucket.leak!
|
33
|
+
bucket.increment!
|
34
|
+
allowed = !bucket.full?
|
35
|
+
begin
|
36
|
+
cache_set(key, bucket)
|
37
|
+
allowed
|
38
|
+
rescue StandardError => e
|
39
|
+
allowed = true
|
40
|
+
# If an error occurred while trying to update the timestamp stored
|
41
|
+
# in the cache, we will fall back to allowing the request through.
|
42
|
+
# This prevents the Rack application blowing up merely due to a
|
43
|
+
# backend cache server (Memcached, Redis, etc.) being offline.
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
###
|
48
|
+
# LeakyBucket is an internal class used to implement the
|
49
|
+
# SlidingWindow limiter strategy. It is a (slightly tweaked)
|
50
|
+
# implementation of the {http://en.wikipedia.org/wiki/Leaky_bucket
|
51
|
+
# Leaky Bucket Algorithm}.
|
52
|
+
class LeakyBucket
|
53
|
+
attr_accessor :maximum, :outflow
|
54
|
+
attr_reader :count, :last_touched
|
55
|
+
|
56
|
+
##
|
57
|
+
# @param [Integer] maximum
|
58
|
+
# @param [Float] outflow
|
59
|
+
def initialize(maximum, outflow)
|
60
|
+
@maximum, @outflow = maximum, outflow
|
61
|
+
@count, @last_touched = 0, Time.now
|
62
|
+
end
|
63
|
+
|
64
|
+
def leak!
|
65
|
+
t = Time.now
|
66
|
+
time = t - last_touched
|
67
|
+
loss = (outflow * time).to_f
|
68
|
+
if loss > 0
|
69
|
+
@count -= loss
|
70
|
+
@last_touched = t
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def increment!
|
75
|
+
@count = 0 if count < 0
|
76
|
+
@count += 1
|
77
|
+
@count = maximum if count > maximum
|
78
|
+
end
|
79
|
+
|
80
|
+
def full?
|
81
|
+
count == maximum
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
end
|
86
|
+
end; end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module Rack; module Throttle
|
2
|
+
##
|
3
|
+
class TimeWindow < Limiter
|
4
|
+
##
|
5
|
+
# Returns `true` if fewer than the maximum number of requests permitted
|
6
|
+
# for the current window of time have been made.
|
7
|
+
#
|
8
|
+
# @param [Rack::Request] request
|
9
|
+
# @return [Boolean]
|
10
|
+
def allowed?(request)
|
11
|
+
count = cache_get(key = cache_key(request)).to_i + 1 rescue 1
|
12
|
+
allowed = count <= max_per_window.to_i
|
13
|
+
begin
|
14
|
+
cache_set(key, count)
|
15
|
+
allowed
|
16
|
+
rescue => e
|
17
|
+
allowed = true
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end; end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module Rack; module Throttle
|
2
|
+
###
|
3
|
+
# This is the base class for matcher implementations.
|
4
|
+
# Implementations are provided for User Agent, URL, and request
|
5
|
+
# method. Subclass Matcher if you want to provide a custom
|
6
|
+
# implementation.
|
7
|
+
class Matcher
|
8
|
+
attr_reader :rule
|
9
|
+
|
10
|
+
def initialize(rule)
|
11
|
+
@rule = rule
|
12
|
+
end
|
13
|
+
|
14
|
+
# Must be implemented in a subclass.
|
15
|
+
# MUST return true or false.
|
16
|
+
# @return [Boolean]
|
17
|
+
# @abstract
|
18
|
+
def match?
|
19
|
+
true
|
20
|
+
end
|
21
|
+
|
22
|
+
# Must be implemented in a subclass.
|
23
|
+
# Used to produce a unique key in our cache store.
|
24
|
+
# Typically of the form "xx-"
|
25
|
+
# @return [String]
|
26
|
+
# @abstract
|
27
|
+
def identifier
|
28
|
+
""
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
end; end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module Rack; module Throttle
|
2
|
+
###
|
3
|
+
# MethodMatchers are used to restrict throttling based on the HTTP
|
4
|
+
# method used by the request. For instance, you may only care about
|
5
|
+
# throttling POST requests on a login form; GET requests are just
|
6
|
+
# fine.
|
7
|
+
# MethodMatchers take Symbol objects of :get, :put, :post, or :delete
|
8
|
+
class MethodMatcher < Matcher
|
9
|
+
##
|
10
|
+
# @param [Rack::Request] request
|
11
|
+
# @return [Boolean]
|
12
|
+
def match?(request)
|
13
|
+
rack_method = :"#{@rule}?"
|
14
|
+
request.send(rack_method)
|
15
|
+
end
|
16
|
+
|
17
|
+
##
|
18
|
+
# @return [String]
|
19
|
+
def identifier
|
20
|
+
"meth-#{@rule}"
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
end; end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module Rack; module Throttle
|
2
|
+
###
|
3
|
+
# UrlMatchers are used to restrict requests based on the URL
|
4
|
+
# requested. For instance, you may care about limiting requests
|
5
|
+
# to a machine-consumed API, but not be concerned about requests
|
6
|
+
# coming from browsers.
|
7
|
+
# UrlMatchers take Regexp object to matcha gainst the request path.
|
8
|
+
class UrlMatcher < Matcher
|
9
|
+
###
|
10
|
+
# @param [Rack::Request] request
|
11
|
+
# @return [Boolean]
|
12
|
+
def match?(request)
|
13
|
+
!!(@rule =~ request.path)
|
14
|
+
end
|
15
|
+
|
16
|
+
###
|
17
|
+
# @return [String]
|
18
|
+
def identifier
|
19
|
+
"url-" + @rule.inspect
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
end; end
|
24
|
+
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Rack; module Throttle
|
2
|
+
###
|
3
|
+
# User Agent Matchers are used to restrict requests based on the User
|
4
|
+
# Agent supplied by the requester. For instance, you may care about
|
5
|
+
# limiting a specific API consumer who reliably uses a known User-Agent.
|
6
|
+
# UserAgentMatchers take Regexp objects to match against the
|
7
|
+
# User-Agent.
|
8
|
+
class UserAgentMatcher < Matcher
|
9
|
+
###
|
10
|
+
# @param [Rack::Request] request
|
11
|
+
# @return [Boolean]
|
12
|
+
def match?(request)
|
13
|
+
!!(@rule =~ request.user_agent)
|
14
|
+
end
|
15
|
+
|
16
|
+
###
|
17
|
+
# @return [String]
|
18
|
+
def identifier
|
19
|
+
"ua-" + @rule.inspect
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
end; end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Rack; module Throttle
|
2
|
+
module VERSION
|
3
|
+
MAJOR = 0
|
4
|
+
MINOR = 8
|
5
|
+
TINY = 0
|
6
|
+
EXTRA = nil
|
7
|
+
|
8
|
+
STRING = [MAJOR, MINOR, TINY].join('.')
|
9
|
+
STRING << "-#{EXTRA}" if EXTRA
|
10
|
+
|
11
|
+
##
|
12
|
+
# @return [String]
|
13
|
+
def self.to_s() STRING end
|
14
|
+
|
15
|
+
##
|
16
|
+
# @return [String]
|
17
|
+
def self.to_str() STRING end
|
18
|
+
|
19
|
+
##
|
20
|
+
# @return [Array(Integer, Integer, Integer)]
|
21
|
+
def self.to_a() [MAJOR, MINOR, TINY] end
|
22
|
+
end
|
23
|
+
end; end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), '..', 'spec_helper')
|
2
|
+
|
3
|
+
describe Rack::Throttle::Daily do
|
4
|
+
include Rack::Test::Methods
|
5
|
+
|
6
|
+
def app
|
7
|
+
@target_app ||= example_target_app
|
8
|
+
@app ||= Rack::Throttle::Daily.new(@target_app, :max_per_day => 3)
|
9
|
+
end
|
10
|
+
|
11
|
+
it "should be allowed if not seen this day" do
|
12
|
+
get "/foo"
|
13
|
+
last_response.body.should show_allowed_response
|
14
|
+
end
|
15
|
+
|
16
|
+
it "should be allowed if seen fewer than the max allowed per day" do
|
17
|
+
2.times { get "/foo" }
|
18
|
+
last_response.body.should show_allowed_response
|
19
|
+
end
|
20
|
+
|
21
|
+
it "should not be allowed if seen more times than the max allowed per day" do
|
22
|
+
4.times { get "/foo" }
|
23
|
+
last_response.body.should show_throttled_response
|
24
|
+
end
|
25
|
+
|
26
|
+
it "should have an associated default_ttl" do
|
27
|
+
app.class.default_ttl.should be_an_instance_of Fixnum
|
28
|
+
end
|
29
|
+
|
30
|
+
# TODO mess with time travelling and requests to make sure no overlap
|
31
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), '..', 'spec_helper')
|
2
|
+
|
3
|
+
describe Rack::Throttle::Hourly do
|
4
|
+
include Rack::Test::Methods
|
5
|
+
|
6
|
+
def app
|
7
|
+
@target_app ||= example_target_app
|
8
|
+
@app ||= Rack::Throttle::Hourly.new(@target_app, :max_per_hour => 3)
|
9
|
+
end
|
10
|
+
|
11
|
+
it "should be allowed if not seen this hour" do
|
12
|
+
get "/foo"
|
13
|
+
last_response.body.should show_allowed_response
|
14
|
+
end
|
15
|
+
|
16
|
+
it "should be allowed if seen fewer than the max allowed per hour" do
|
17
|
+
2.times { get "/foo" }
|
18
|
+
last_response.body.should show_allowed_response
|
19
|
+
end
|
20
|
+
|
21
|
+
it "should not be allowed if seen more times than the max allowed per hour" do
|
22
|
+
4.times { get "/foo" }
|
23
|
+
last_response.body.should show_throttled_response
|
24
|
+
end
|
25
|
+
|
26
|
+
it "should have an associated default_ttl" do
|
27
|
+
app.class.default_ttl.should be_an_instance_of Fixnum
|
28
|
+
end
|
29
|
+
|
30
|
+
|
31
|
+
# TODO mess with time travelling and requests to make sure no overlap
|
32
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), '..', 'spec_helper')
|
2
|
+
|
3
|
+
|
4
|
+
describe Rack::Throttle::Interval do
|
5
|
+
include Rack::Test::Methods
|
6
|
+
|
7
|
+
def app
|
8
|
+
@target_app ||= example_target_app
|
9
|
+
@app ||= Rack::Throttle::Interval.new(@target_app, :min => 0.1)
|
10
|
+
end
|
11
|
+
|
12
|
+
it "should allow the request if the source has not been seen" do
|
13
|
+
get "/foo"
|
14
|
+
last_response.body.should show_allowed_response
|
15
|
+
end
|
16
|
+
|
17
|
+
it "should allow the request if the source has not been seen in the current interval" do
|
18
|
+
Timecop.freeze do
|
19
|
+
get "/foo"
|
20
|
+
Timecop.freeze(1) do # Timecop.freeze won't do subsecond resolution
|
21
|
+
get "/foo"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
last_response.body.should show_allowed_response
|
25
|
+
end
|
26
|
+
|
27
|
+
it "should not allow the request if the source has been seen inside the current interval" do
|
28
|
+
Timecop.freeze do
|
29
|
+
2.times { get "/foo" }
|
30
|
+
end
|
31
|
+
last_response.body.should show_throttled_response
|
32
|
+
end
|
33
|
+
|
34
|
+
it "should gracefully allow the request if the cache bombs on getting" do
|
35
|
+
app.should_receive(:cache_get).and_raise(StandardError)
|
36
|
+
get "/foo"
|
37
|
+
last_response.body.should show_allowed_response
|
38
|
+
end
|
39
|
+
|
40
|
+
it "should gracefully allow the request if the cache bombs on setting" do
|
41
|
+
app.should_receive(:cache_set).and_raise(StandardError)
|
42
|
+
get "/foo"
|
43
|
+
last_response.body.should show_allowed_response
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), '..', 'spec_helper')
|
2
|
+
|
3
|
+
describe Rack::Throttle::Limiter do
|
4
|
+
include Rack::Test::Methods
|
5
|
+
|
6
|
+
def app
|
7
|
+
@target_app ||= example_target_app
|
8
|
+
@app ||= Rack::Throttle::Limiter.new(@target_app)
|
9
|
+
end
|
10
|
+
|
11
|
+
describe "basic calling" do
|
12
|
+
it "should return the example app" do
|
13
|
+
get "/foo"
|
14
|
+
last_response.body.should show_allowed_response
|
15
|
+
end
|
16
|
+
|
17
|
+
it "should call the application if allowed" do
|
18
|
+
app.should_receive(:allowed?).and_return(true)
|
19
|
+
get "/foo"
|
20
|
+
last_response.body.should show_allowed_response
|
21
|
+
end
|
22
|
+
|
23
|
+
it "should give a rate limit exceeded message if not allowed" do
|
24
|
+
app.should_receive(:allowed?).and_return(false)
|
25
|
+
get "/foo"
|
26
|
+
last_response.body.should show_throttled_response
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
describe "allowed?" do
|
31
|
+
it "should return true if whitelisted" do
|
32
|
+
app.should_receive(:whitelisted?).and_return(true)
|
33
|
+
get "/foo"
|
34
|
+
last_response.body.should show_allowed_response
|
35
|
+
end
|
36
|
+
|
37
|
+
it "should return false if blacklisted" do
|
38
|
+
app.should_receive(:blacklisted?).and_return(true)
|
39
|
+
get "/foo"
|
40
|
+
last_response.body.should show_throttled_response
|
41
|
+
end
|
42
|
+
|
43
|
+
it "should return true if not whitelisted or blacklisted" do
|
44
|
+
app.should_receive(:whitelisted?).and_return(false)
|
45
|
+
app.should_receive(:blacklisted?).and_return(false)
|
46
|
+
get "/foo"
|
47
|
+
last_response.body.should show_allowed_response
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), '..', 'spec_helper')
|
2
|
+
|
3
|
+
describe Rack::Throttle::SlidingWindow do
|
4
|
+
include Rack::Test::Methods
|
5
|
+
|
6
|
+
def app
|
7
|
+
@target_app ||= example_target_app
|
8
|
+
@app ||= Rack::Throttle::SlidingWindow.new(@target_app, :burst => 2, :average => 1)
|
9
|
+
end
|
10
|
+
|
11
|
+
before(:each) do
|
12
|
+
Timecop.freeze
|
13
|
+
@time = Time.now
|
14
|
+
end
|
15
|
+
|
16
|
+
after(:each) do
|
17
|
+
Timecop.return
|
18
|
+
end
|
19
|
+
|
20
|
+
it "should allow the request if the source has not been seen at all" do
|
21
|
+
get "/foo"
|
22
|
+
last_response.body.should show_allowed_response
|
23
|
+
end
|
24
|
+
|
25
|
+
it "should allow the request if the rate is above-average but within the burst rule" do
|
26
|
+
Timecop.freeze(@time) { get "/foo" }
|
27
|
+
Timecop.freeze(@time + 0.5) { get "/foo" }
|
28
|
+
last_response.body.should show_allowed_response
|
29
|
+
end
|
30
|
+
|
31
|
+
it "should not allow the request if the rate is greater than the burst rule" do
|
32
|
+
Timecop.freeze(@time) { get "/foo" }
|
33
|
+
Timecop.freeze(@time + 0.3) { get "/foo" }
|
34
|
+
Timecop.freeze(@time + 0.6) { get "/foo" }
|
35
|
+
last_response.body.should show_throttled_response
|
36
|
+
end
|
37
|
+
|
38
|
+
it "should allow the request if the rate is less than the average" do
|
39
|
+
Timecop.freeze(@time) { get "/foo" }
|
40
|
+
Timecop.freeze(@time + 0.5) { get "/foo" }
|
41
|
+
Timecop.freeze(@time + 2) { get "/foo" }
|
42
|
+
|
43
|
+
last_response.body.should show_allowed_response
|
44
|
+
end
|
45
|
+
|
46
|
+
it "should not allow the request if the rate is more than the average" do
|
47
|
+
Timecop.freeze(@time) { get "/foo" }
|
48
|
+
Timecop.freeze(@time + 0.5) { get "/foo" }
|
49
|
+
Timecop.freeze(@time + 1) { get "/foo" }
|
50
|
+
Timecop.freeze(@time + 1.5) { get "/foo" }
|
51
|
+
|
52
|
+
last_response.body.should show_throttled_response
|
53
|
+
end
|
54
|
+
|
55
|
+
it "should gracefully allow the request if the cache bombs on getting" do
|
56
|
+
app.should_receive(:cache_get).and_raise(StandardError)
|
57
|
+
get "/foo"
|
58
|
+
last_response.body.should show_allowed_response
|
59
|
+
end
|
60
|
+
|
61
|
+
it "should gracefully allow the request if the cache bombs on setting" do
|
62
|
+
app.should_receive(:cache_set).and_raise(StandardError)
|
63
|
+
get "/foo"
|
64
|
+
last_response.body.should show_allowed_response
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|