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.
@@ -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
+