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/etc/memcache.ru ADDED
@@ -0,0 +1,8 @@
1
+ $:.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
2
+ require 'rack/throttle'
3
+ gem 'memcache'
4
+ require 'memcache'
5
+
6
+ use Rack::Throttle::Interval, :min => 3.0, :cache => Memcache.new(:server => 'localhost:11211')
7
+
8
+ run lambda { |env| [200, {'Content-Type' => 'text/plain'}, "Hello, world!\n"] }
data/etc/memcached.ru ADDED
@@ -0,0 +1,8 @@
1
+ $:.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
2
+ require 'rack/throttle'
3
+ gem 'memcached'
4
+ require 'memcached'
5
+
6
+ use Rack::Throttle::Interval, :min => 3.0, :cache => Memcached.new
7
+
8
+ run lambda { |env| [200, {'Content-Type' => 'text/plain'}, "Hello, world!\n"] }
data/etc/redis.ru ADDED
@@ -0,0 +1,8 @@
1
+ $:.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
2
+ require 'rack/throttle'
3
+ gem 'redis'
4
+ require 'redis'
5
+
6
+ use Rack::Throttle::Interval, :min => 3.0, :cache => Redis.new
7
+
8
+ run lambda { |env| [200, {'Content-Type' => 'text/plain'}, "Hello, world!\n"] }
@@ -0,0 +1,94 @@
1
+ # Generated by jeweler
2
+ # DO NOT EDIT THIS FILE DIRECTLY
3
+ # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
4
+ # -*- encoding: utf-8 -*-
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = "improved-rack-throttle-w-expiry"
8
+ s.version = "0.8.0"
9
+
10
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
+ s.authors = ["Ben Somers", "Arto Bendiken", "Brendon Murphy", "Shane Moore"]
12
+ s.date = "2013-06-17"
13
+ s.description = "Rack middleware for rate-limiting incoming HTTP requests."
14
+ s.email = "shane@ninja.ie"
15
+ s.extra_rdoc_files = [
16
+ "README.md", "ROADMAP.md"
17
+ ]
18
+ s.files = [
19
+ ".document",
20
+ "Gemfile",
21
+ "Gemfile.lock",
22
+ "README.md",
23
+ "Rakefile",
24
+ "UNLICENSE",
25
+ "doc/.gitignore",
26
+ "etc/gdbm.ru",
27
+ "etc/hash.ru",
28
+ "etc/memcache-client.ru",
29
+ "etc/memcache.ru",
30
+ "etc/memcached.ru",
31
+ "etc/redis.ru",
32
+ "improved-rack-throttle-w-expiry.gemspec",
33
+ "lib/rack/throttle.rb",
34
+ "lib/rack/throttle/limiters/daily.rb",
35
+ "lib/rack/throttle/limiters/hourly.rb",
36
+ "lib/rack/throttle/limiters/interval.rb",
37
+ "lib/rack/throttle/limiters/limiter.rb",
38
+ "lib/rack/throttle/limiters/sliding_window.rb",
39
+ "lib/rack/throttle/limiters/time_window.rb",
40
+ "lib/rack/throttle/matchers/matcher.rb",
41
+ "lib/rack/throttle/matchers/method_matcher.rb",
42
+ "lib/rack/throttle/matchers/url_matcher.rb",
43
+ "lib/rack/throttle/matchers/user_agent_matcher.rb",
44
+ "lib/rack/throttle/version.rb",
45
+ "spec/limiters/daily_spec.rb",
46
+ "spec/limiters/hourly_spec.rb",
47
+ "spec/limiters/interval_spec.rb",
48
+ "spec/limiters/limiter_spec.rb",
49
+ "spec/limiters/sliding_window_spec.rb",
50
+ "spec/matchers/method_matcher_spec.rb",
51
+ "spec/matchers/url_matcher_spec.rb",
52
+ "spec/matchers/user_agent_matcher_spec.rb",
53
+ "spec/spec_helper.rb"
54
+ ]
55
+ s.homepage = "http://github.com/Rooktone/improved-rack-throttle-w-expiry"
56
+ s.licenses = ["Public Domain"]
57
+ s.require_paths = ["lib"]
58
+ s.rubygems_version = "1.8.25"
59
+ s.summary = "HTTP request rate limiter for Rack applications."
60
+
61
+ if s.respond_to? :specification_version then
62
+ s.specification_version = 3
63
+
64
+ if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
65
+ s.add_runtime_dependency(%q<rack>, [">= 1.0.0"])
66
+ s.add_development_dependency(%q<timecop>, ["~> 0.5.2"])
67
+ s.add_development_dependency(%q<rack-test>, ["~> 0.6.2"])
68
+ s.add_development_dependency(%q<rspec>, ["~> 2.11.0"])
69
+ s.add_development_dependency(%q<yard>, [">= 0.5.5"])
70
+ s.add_development_dependency(%q<redcarpet>, [">= 0"])
71
+ s.add_development_dependency(%q<rake>, [">= 0"])
72
+ s.add_development_dependency(%q<jeweler>, [">= 0"])
73
+ else
74
+ s.add_dependency(%q<rack>, [">= 1.0.0"])
75
+ s.add_dependency(%q<timecop>, ["~> 0.5.2"])
76
+ s.add_dependency(%q<rack-test>, ["~> 0.6.2"])
77
+ s.add_dependency(%q<rspec>, ["~> 2.11.0"])
78
+ s.add_dependency(%q<yard>, [">= 0.5.5"])
79
+ s.add_dependency(%q<redcarpet>, [">= 0"])
80
+ s.add_dependency(%q<rake>, [">= 0"])
81
+ s.add_dependency(%q<jeweler>, [">= 0"])
82
+ end
83
+ else
84
+ s.add_dependency(%q<rack>, [">= 1.0.0"])
85
+ s.add_dependency(%q<timecop>, ["~> 0.5.2"])
86
+ s.add_dependency(%q<rack-test>, ["~> 0.6.2"])
87
+ s.add_dependency(%q<rspec>, ["~> 2.11.0"])
88
+ s.add_dependency(%q<yard>, [">= 0.5.5"])
89
+ s.add_dependency(%q<redcarpet>, [">= 0"])
90
+ s.add_dependency(%q<rake>, [">= 0"])
91
+ s.add_dependency(%q<jeweler>, [">= 0"])
92
+ end
93
+ end
94
+
@@ -0,0 +1,17 @@
1
+ require 'rack'
2
+
3
+ module Rack
4
+ module Throttle
5
+ autoload :Limiter, 'rack/throttle/limiters/limiter'
6
+ autoload :Interval, 'rack/throttle/limiters/interval'
7
+ autoload :TimeWindow, 'rack/throttle/limiters/time_window'
8
+ autoload :Daily, 'rack/throttle/limiters/daily'
9
+ autoload :Hourly, 'rack/throttle/limiters/hourly'
10
+ autoload :SlidingWindow, 'rack/throttle/limiters/sliding_window'
11
+ autoload :VERSION, 'rack/throttle/version'
12
+ autoload :Matcher, 'rack/throttle/matchers/matcher'
13
+ autoload :UrlMatcher, 'rack/throttle/matchers/url_matcher'
14
+ autoload :MethodMatcher, 'rack/throttle/matchers/method_matcher'
15
+ autoload :UserAgentMatcher, 'rack/throttle/matchers/user_agent_matcher'
16
+ end
17
+ end
@@ -0,0 +1,49 @@
1
+ module Rack; module Throttle
2
+ ##
3
+ # This rate limiter strategy throttles the application by defining a
4
+ # maximum number of allowed HTTP requests per day (by default, 86,400
5
+ # requests per 24 hours, which works out to an average of 1 request per
6
+ # second).
7
+ #
8
+ # Note that this strategy doesn't use a sliding time window, but rather
9
+ # tracks requests per calendar day. This means that the throttling counter
10
+ # is reset at midnight (according to the server's local timezone) every
11
+ # night.
12
+ #
13
+ # @example Allowing up to 86,400 requests per day
14
+ # use Rack::Throttle::Daily
15
+ #
16
+ # @example Allowing up to 1,000 requests per day
17
+ # use Rack::Throttle::Daily, :max => 1000
18
+ #
19
+ class Daily < TimeWindow
20
+ ##
21
+ # @param [#call] app
22
+ # @param [Hash{Symbol => Object}] options
23
+ # @option options [Integer] :max (86400)
24
+
25
+ def initialize(app, options = {})
26
+ super
27
+ end
28
+
29
+ ##
30
+ def max_per_day
31
+ @max_per_hour ||= options[:max_per_day] || options[:max] || 86_400
32
+ end
33
+
34
+ def self.default_ttl
35
+ ENV['RACK_THROTTLE_DAILY_TTL'] || 86400
36
+ end
37
+
38
+ alias_method :max_per_window, :max_per_day
39
+
40
+ protected
41
+
42
+ ##
43
+ # @param [Rack::Request] request
44
+ # @return [String]
45
+ def cache_key(request)
46
+ [super, Time.now.strftime('%Y-%m-%d')].join(':')
47
+ end
48
+ end
49
+ end; end
@@ -0,0 +1,49 @@
1
+ module Rack; module Throttle
2
+ ##
3
+ # This rate limiter strategy throttles the application by defining a
4
+ # maximum number of allowed HTTP requests per hour (by default, 3,600
5
+ # requests per 60 minutes, which works out to an average of 1 request per
6
+ # second).
7
+ #
8
+ # Note that this strategy doesn't use a sliding time window, but rather
9
+ # tracks requests per distinct hour. This means that the throttling
10
+ # counter is reset every hour on the hour (according to the server's local
11
+ # timezone).
12
+ #
13
+ # @example Allowing up to 3,600 requests per hour
14
+ # use Rack::Throttle::Hourly
15
+ #
16
+ # @example Allowing up to 100 requests per hour
17
+ # use Rack::Throttle::Hourly, :max => 100
18
+ #
19
+ class Hourly < TimeWindow
20
+ ##
21
+ # @param [#call] app
22
+ # @param [Hash{Symbol => Object}] options
23
+ # @option options [Integer] :max (3600)
24
+
25
+ def initialize(app, options = {})
26
+ super
27
+ end
28
+
29
+ ##
30
+ def max_per_hour
31
+ @max_per_hour ||= options[:max_per_hour] || options[:max] || 3_600
32
+ end
33
+
34
+ def self.default_ttl
35
+ ENV['RACK_THROTTLE_HOURLY_TTL'] || 3600
36
+ end
37
+
38
+ alias_method :max_per_window, :max_per_hour
39
+
40
+ protected
41
+
42
+ ##
43
+ # @param [Rack::Request] request
44
+ # @return [String]
45
+ def cache_key(request)
46
+ [super, Time.now.strftime('%Y-%m-%dT%H')].join(':')
47
+ end
48
+ end
49
+ end; end
@@ -0,0 +1,63 @@
1
+ module Rack; module Throttle
2
+ ##
3
+ # This rate limiter strategy throttles the application by enforcing a
4
+ # minimum interval (by default, 1 second) between subsequent allowed HTTP
5
+ # requests.
6
+ #
7
+ # @example Allowing up to two requests per second
8
+ # use Rack::Throttle::Interval, :min => 0.5 # 500 ms interval
9
+ #
10
+ # @example Allowing a request every two seconds
11
+ # use Rack::Throttle::Interval, :min => 2.0 # 2000 ms interval
12
+ #
13
+ class Interval < Limiter
14
+ ##
15
+ # @param [#call] app
16
+ # @param [Hash{Symbol => Object}] options
17
+ # @option options [Float] :min (1.0)
18
+ def initialize(app, options = {})
19
+ super
20
+ end
21
+
22
+ ##
23
+ # Returns `true` if sufficient time (equal to or more than
24
+ # {#minimum_interval}) has passed since the last request and the given
25
+ # present `request`.
26
+ #
27
+ # @param [Rack::Request] request
28
+ # @return [Boolean]
29
+ def allowed?(request)
30
+ t1 = request_start_time(request)
31
+ t0 = cache_get(key = cache_key(request)) rescue nil
32
+ allowed = !t0 || (dt = t1 - t0.to_f) >= minimum_interval
33
+ begin
34
+ cache_set(key, t1)
35
+ allowed
36
+ rescue StandardError => e
37
+ allowed = true
38
+ # If an error occurred while trying to update the timestamp stored
39
+ # in the cache, we will fall back to allowing the request through.
40
+ # This prevents the Rack application blowing up merely due to a
41
+ # backend cache server (Memcached, Redis, etc.) being offline.
42
+ end
43
+ end
44
+
45
+ ##
46
+ # Returns the number of seconds before the client is allowed to retry an
47
+ # HTTP request.
48
+ #
49
+ # @return [Float]
50
+ def retry_after
51
+ minimum_interval
52
+ end
53
+
54
+ ##
55
+ # Returns the required minimal interval (in terms of seconds) that must
56
+ # elapse between two subsequent HTTP requests.
57
+ #
58
+ # @return [Float]
59
+ def minimum_interval
60
+ @min ||= (@options[:min] || 1.0).to_f
61
+ end
62
+ end
63
+ end; end
@@ -0,0 +1,231 @@
1
+ module Rack; module Throttle
2
+ ##
3
+ # This is the base class for rate limiter implementations.
4
+ #
5
+ # @example Defining a rate limiter subclass
6
+ # class MyLimiter < Limiter
7
+ # def allowed?(request)
8
+ # # TODO: custom logic goes here
9
+ # end
10
+ # end
11
+ #
12
+ class Limiter
13
+ attr_reader :app, :options, :matchers
14
+
15
+ ##
16
+ # @param [#call] app
17
+ # @param [Hash{Symbol => Object}] options
18
+ # @option options [String] :cache (Hash.new)
19
+ # @option options [String] :key (nil)
20
+ # @option options [String] :key_prefix (nil)
21
+ # @option options [Integer] :code (403)
22
+ # @option options [String] :message ("Rate Limit Exceeded")
23
+ def initialize(app, options = {})
24
+ rules = options.delete(:rules) || {}
25
+ @app, @options, @matchers = app, options, []
26
+ @matchers += Array(rules[:ip]).map { |rule| IpMatcher.new(rule) } if rules[:ip]
27
+ @matchers += Array(rules[:url]).map { |rule| UrlMatcher.new(rule) } if rules[:url]
28
+ @matchers += Array(rules[:user_agent]).map { |rule| UserAgentMatcher.new(rule) } if rules[:user_agent]
29
+ @matchers += Array(rules[:method]).map { |rule| MethodMatcher.new(rule) } if rules[:method]
30
+ end
31
+
32
+ ##
33
+ # @param [Hash{String => String}] env
34
+ # @return [Array(Integer, Hash, #each)]
35
+ # @see http://rack.rubyforge.org/doc/SPEC.html
36
+ def call(env)
37
+ request = Rack::Request.new(env)
38
+ match_results = @matchers.map { |m| m.match?(request) }.uniq
39
+ applicable = @matchers.empty? || match_results == [true]
40
+ if applicable and !allowed?(request)
41
+ rate_limit_exceeded
42
+ else
43
+ app.call(env)
44
+ end
45
+ end
46
+
47
+ ##
48
+ # Returns `true` if no :url_rule regex or if the request path
49
+ # matches the :url regex, `false` otherwise.
50
+ #
51
+ # You can override this class, though that might be weird.
52
+ #
53
+ # @param [String] path
54
+ # @return [Boolean]
55
+ def restricted_url?(path)
56
+ options[:url_rule].nil? || options[:url_rule].match(path)
57
+ end
58
+
59
+ ##
60
+ # Returns `false` if the rate limit has been exceeded for the given
61
+ # `request`, or `true` otherwise.
62
+ #
63
+ # Override this method in subclasses that implement custom rate limiter
64
+ # strategies.
65
+ #
66
+ # @param [Rack::Request] request
67
+ # @return [Boolean]
68
+ def allowed?(request)
69
+ case
70
+ when whitelisted?(request) then true
71
+ when blacklisted?(request) then false
72
+ else true # override in subclasses
73
+ end
74
+ end
75
+
76
+ ##
77
+ # Returns `true` if the originator of the given `request` is whitelisted
78
+ # (not subject to further rate limits).
79
+ #
80
+ # The default implementation always returns `false`. Override this
81
+ # method in a subclass to implement custom whitelisting logic.
82
+ #
83
+ # @param [Rack::Request] request
84
+ # @return [Boolean]
85
+ # @abstract
86
+ def whitelisted?(request)
87
+ false
88
+ end
89
+
90
+ ##
91
+ # Returns `true` if the originator of the given `request` is blacklisted
92
+ # (not honoring rate limits, and thus permanently forbidden access
93
+ # without the need to maintain further rate limit counters).
94
+ #
95
+ # The default implementation always returns `false`. Override this
96
+ # method in a subclass to implement custom blacklisting logic.
97
+ #
98
+ # @param [Rack::Request] request
99
+ # @return [Boolean]
100
+ # @abstract
101
+ def blacklisted?(request)
102
+ false
103
+ end
104
+
105
+ protected
106
+
107
+ ##
108
+ # @return [Hash]
109
+ def cache
110
+ case cache = (options[:cache] ||= {})
111
+ when Proc then cache.call
112
+ else cache
113
+ end
114
+ end
115
+
116
+ ##
117
+ # @param [String] key
118
+ def cache_has?(key)
119
+ case
120
+ when cache.respond_to?(:has_key?)
121
+ cache.has_key?(key)
122
+ when cache.respond_to?(:get)
123
+ cache.get(key) rescue false
124
+ else false
125
+ end
126
+ end
127
+
128
+ ##
129
+ # @param [String] key
130
+ # @return [Object]
131
+ def cache_get(key, default = nil)
132
+ case
133
+ when cache.respond_to?(:[])
134
+ cache[key] || default
135
+ when cache.respond_to?(:get)
136
+ cache.get(key) || default
137
+ end
138
+ end
139
+
140
+ ##
141
+ # @param [String] key
142
+ # @param [Object] value
143
+ # @return [void]
144
+ def cache_set(key, value, scheme = Hourly)
145
+ case
146
+ when cache.respond_to?(:[]=)
147
+ begin
148
+ cache[key] = value
149
+ rescue TypeError => e
150
+ # GDBM throws a "TypeError: can't convert Float into String"
151
+ # exception when trying to store a Float. On the other hand, we
152
+ # don't want to unnecessarily coerce the value to a String for
153
+ # any stores that do support other data types (e.g. in-memory
154
+ # hash objects). So, this is a compromise.
155
+ cache[key] = value.to_s
156
+ end
157
+ when cache.respond_to?(:set)
158
+ cache.set(key, value)
159
+ end
160
+ if cache.respond_to?(:expire)
161
+ cache.expire(key, scheme.default_ttl)
162
+ end
163
+ end
164
+
165
+
166
+ ##
167
+ # @param [Rack::Request] request
168
+ # @return [String]
169
+ def cache_key(request)
170
+ id = client_identifier(request)
171
+ id = options[:key].call(request) if options.has_key?(:key)
172
+ id = [options[:key_prefix], id].join(':') if options.has_key?(:key_prefix)
173
+ @matchers.each do |matcher|
174
+ id += ":#{matcher.identifier}"
175
+ end
176
+
177
+ id
178
+ end
179
+
180
+ ##
181
+ # @param [Rack::Request] request
182
+ # @return [String]
183
+ def client_identifier(request)
184
+ request.ip.to_s
185
+ end
186
+
187
+ ##
188
+ # @param [Rack::Request] request
189
+ # @return [Float]
190
+ def request_start_time(request)
191
+ case
192
+ when request.env.has_key?('HTTP_X_REQUEST_START')
193
+ request.env['HTTP_X_REQUEST_START'].to_f / 1000
194
+ else
195
+ Time.now.to_f
196
+ end
197
+ end
198
+
199
+ ##
200
+ # Outputs a `Rate Limit Exceeded` error.
201
+ #
202
+ # @return [Array(Integer, Hash, #each)]
203
+ def rate_limit_exceeded
204
+ headers = respond_to?(:retry_after) ? {'Retry-After' => retry_after.to_f.ceil.to_s} : {}
205
+ http_error(options[:code] || 403, options[:message] || 'Rate Limit Exceeded', headers)
206
+ end
207
+
208
+ ##
209
+ # Outputs an HTTP `4xx` or `5xx` response.
210
+ #
211
+ # @param [Integer] code
212
+ # @param [String, #to_s] message
213
+ # @param [Hash{String => String}] headers
214
+ # @return [Array(Integer, Hash, #each)]
215
+ def http_error(code, message = nil, headers = {})
216
+ [ code,
217
+ { 'Content-Type' => 'text/plain; charset=utf-8' }.merge(headers),
218
+ Array( http_status(code) + (message.nil? ? "\n" : " (#{message})\n") )
219
+ ]
220
+ end
221
+
222
+ ##
223
+ # Returns the standard HTTP status message for the given status `code`.
224
+ #
225
+ # @param [Integer] code
226
+ # @return [String]
227
+ def http_status(code)
228
+ [code, Rack::Utils::HTTP_STATUS_CODES[code]].join(' ')
229
+ end
230
+ end
231
+ end; end