improved-rack-throttle 0.5.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,44 @@
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
+ def initialize(app, options = {})
25
+ super
26
+ end
27
+
28
+ ##
29
+ def max_per_day
30
+ @max_per_hour ||= options[:max_per_day] || options[:max] || 86_400
31
+ end
32
+
33
+ alias_method :max_per_window, :max_per_day
34
+
35
+ protected
36
+
37
+ ##
38
+ # @param [Rack::Request] request
39
+ # @return [String]
40
+ def cache_key(request)
41
+ [super, Time.now.strftime('%Y-%m-%d')].join(':')
42
+ end
43
+ end
44
+ end; end
@@ -0,0 +1,44 @@
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
+ def initialize(app, options = {})
25
+ super
26
+ end
27
+
28
+ ##
29
+ def max_per_hour
30
+ @max_per_hour ||= options[:max_per_hour] || options[:max] || 3_600
31
+ end
32
+
33
+ alias_method :max_per_window, :max_per_hour
34
+
35
+ protected
36
+
37
+ ##
38
+ # @param [Rack::Request] request
39
+ # @return [String]
40
+ def cache_key(request)
41
+ [super, Time.now.strftime('%Y-%m-%dT%H')].join(':')
42
+ end
43
+ end
44
+ 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,228 @@
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
+ # @option options [Regexp] :url_rule (nil)
24
+ def initialize(app, options = {})
25
+ rules = options.delete(:rules) || {}
26
+ @app, @options, @matchers = app, options, []
27
+ @matchers += Array(rules[:ip]).map { |rule| IpMatcher.new(rule) } if rules[:ip]
28
+ @matchers += Array(rules[:url]).map { |rule| UrlMatcher.new(rule) } if rules[:url]
29
+ @matchers += Array(rules[:user_agent]).map { |rule| UserAgentMatcher.new(rule) } if rules[:user_agent]
30
+ @matchers += Array(rules[:method]).map { |rule| MethodMatcher.new(rule) } if rules[:method]
31
+ end
32
+
33
+ ##
34
+ # @param [Hash{String => String}] env
35
+ # @return [Array(Integer, Hash, #each)]
36
+ # @see http://rack.rubyforge.org/doc/SPEC.html
37
+ def call(env)
38
+ request = Rack::Request.new(env)
39
+ match_results = @matchers.map { |m| m.match?(request) }.uniq
40
+ applicable = @matchers.empty? || match_results == [true]
41
+ if applicable and !allowed?(request)
42
+ rate_limit_exceeded
43
+ else
44
+ app.call(env)
45
+ end
46
+ end
47
+
48
+ ##
49
+ # Returns `true` if no :url_rule regex or if the request path
50
+ # matches the :url regex, `false` otherwise.
51
+ #
52
+ # You can override this class, though that might be weird.
53
+ #
54
+ # @param [String] path
55
+ # @return [Boolean]
56
+ def restricted_url?(path)
57
+ options[:url_rule].nil? || options[:url_rule].match(path)
58
+ end
59
+
60
+ ##
61
+ # Returns `false` if the rate limit has been exceeded for the given
62
+ # `request`, or `true` otherwise.
63
+ #
64
+ # Override this method in subclasses that implement custom rate limiter
65
+ # strategies.
66
+ #
67
+ # @param [Rack::Request] request
68
+ # @return [Boolean]
69
+ def allowed?(request)
70
+ case
71
+ when whitelisted?(request) then true
72
+ when blacklisted?(request) then false
73
+ else true # override in subclasses
74
+ end
75
+ end
76
+
77
+ ##
78
+ # Returns `true` if the originator of the given `request` is whitelisted
79
+ # (not subject to further rate limits).
80
+ #
81
+ # The default implementation always returns `false`. Override this
82
+ # method in a subclass to implement custom whitelisting logic.
83
+ #
84
+ # @param [Rack::Request] request
85
+ # @return [Boolean]
86
+ # @abstract
87
+ def whitelisted?(request)
88
+ false
89
+ end
90
+
91
+ ##
92
+ # Returns `true` if the originator of the given `request` is blacklisted
93
+ # (not honoring rate limits, and thus permanently forbidden access
94
+ # without the need to maintain further rate limit counters).
95
+ #
96
+ # The default implementation always returns `false`. Override this
97
+ # method in a subclass to implement custom blacklisting logic.
98
+ #
99
+ # @param [Rack::Request] request
100
+ # @return [Boolean]
101
+ # @abstract
102
+ def blacklisted?(request)
103
+ false
104
+ end
105
+
106
+ protected
107
+
108
+ ##
109
+ # @return [Hash]
110
+ def cache
111
+ case cache = (options[:cache] ||= {})
112
+ when Proc then cache.call
113
+ else cache
114
+ end
115
+ end
116
+
117
+ ##
118
+ # @param [String] key
119
+ def cache_has?(key)
120
+ case
121
+ when cache.respond_to?(:has_key?)
122
+ cache.has_key?(key)
123
+ when cache.respond_to?(:get)
124
+ cache.get(key) rescue false
125
+ else false
126
+ end
127
+ end
128
+
129
+ ##
130
+ # @param [String] key
131
+ # @return [Object]
132
+ def cache_get(key, default = nil)
133
+ case
134
+ when cache.respond_to?(:[])
135
+ cache[key] || default
136
+ when cache.respond_to?(:get)
137
+ cache.get(key) || default
138
+ end
139
+ end
140
+
141
+ ##
142
+ # @param [String] key
143
+ # @param [Object] value
144
+ # @return [void]
145
+ def cache_set(key, value)
146
+ case
147
+ when cache.respond_to?(:[]=)
148
+ begin
149
+ cache[key] = value
150
+ rescue TypeError => e
151
+ # GDBM throws a "TypeError: can't convert Float into String"
152
+ # exception when trying to store a Float. On the other hand, we
153
+ # don't want to unnecessarily coerce the value to a String for
154
+ # any stores that do support other data types (e.g. in-memory
155
+ # hash objects). So, this is a compromise.
156
+ cache[key] = value.to_s
157
+ end
158
+ when cache.respond_to?(:set)
159
+ cache.set(key, value)
160
+ end
161
+ end
162
+
163
+ ##
164
+ # @param [Rack::Request] request
165
+ # @return [String]
166
+ def cache_key(request)
167
+ id = client_identifier(request)
168
+ id = options[:key].call(request) if options.has_key?(:key)
169
+ id = [options[:key_prefix], id].join(':') if options.has_key?(:key_prefix)
170
+ @matchers.each do |matcher|
171
+ id += ":#{matcher.identifier}"
172
+ end
173
+
174
+ id
175
+ end
176
+
177
+ ##
178
+ # @param [Rack::Request] request
179
+ # @return [String]
180
+ def client_identifier(request)
181
+ request.ip.to_s
182
+ end
183
+
184
+ ##
185
+ # @param [Rack::Request] request
186
+ # @return [Float]
187
+ def request_start_time(request)
188
+ case
189
+ when request.env.has_key?('HTTP_X_REQUEST_START')
190
+ request.env['HTTP_X_REQUEST_START'].to_f / 1000
191
+ else
192
+ Time.now.to_f
193
+ end
194
+ end
195
+
196
+ ##
197
+ # Outputs a `Rate Limit Exceeded` error.
198
+ #
199
+ # @return [Array(Integer, Hash, #each)]
200
+ def rate_limit_exceeded
201
+ headers = respond_to?(:retry_after) ? {'Retry-After' => retry_after.to_f.ceil.to_s} : {}
202
+ http_error(options[:code] || 403, options[:message] || 'Rate Limit Exceeded', headers)
203
+ end
204
+
205
+ ##
206
+ # Outputs an HTTP `4xx` or `5xx` response.
207
+ #
208
+ # @param [Integer] code
209
+ # @param [String, #to_s] message
210
+ # @param [Hash{String => String}] headers
211
+ # @return [Array(Integer, Hash, #each)]
212
+ def http_error(code, message = nil, headers = {})
213
+ [ code,
214
+ { 'Content-Type' => 'text/plain; charset=utf-8' }.merge(headers),
215
+ Array( http_status(code) + (message.nil? ? "\n" : " (#{message})\n") )
216
+ ]
217
+ end
218
+
219
+ ##
220
+ # Returns the standard HTTP status message for the given status `code`.
221
+ #
222
+ # @param [Integer] code
223
+ # @return [String]
224
+ def http_status(code)
225
+ [code, Rack::Utils::HTTP_STATUS_CODES[code]].join(' ')
226
+ end
227
+ end
228
+ end; end
@@ -0,0 +1,24 @@
1
+ module Rack; module Throttle
2
+ ###
3
+ # This is the base class for matcher implementations
4
+ # Implements IP, user agent, url, and request method based matching
5
+ # e.g. implement throttling rules that discriminate by ip, user agent, url, request method,
6
+ # or any combination thereof
7
+ class Matcher
8
+ attr_reader :rule
9
+
10
+ def initialize(rule)
11
+ @rule = rule
12
+ end
13
+
14
+ # MUST return true or false
15
+ def match?
16
+ true
17
+ end
18
+
19
+ def identifier
20
+ ""
21
+ end
22
+ end
23
+
24
+ end; end
@@ -0,0 +1,14 @@
1
+ module Rack; module Throttle
2
+ ###
3
+ # IpMatchers take RegExp objects and compare the request ip against them
4
+ class IpMatcher < Matcher
5
+ def match?(request)
6
+ !!(@rule =~ request.ip)
7
+ end
8
+
9
+ def identifier
10
+ "ip" + @rule.inspect
11
+ end
12
+ end
13
+
14
+ end; end
@@ -0,0 +1,15 @@
1
+ module Rack; module Throttle
2
+ ###
3
+ # MethodMatchers take Symbol objects of :get, :put, :post, or :delete
4
+ class MethodMatcher < Matcher
5
+ def match?(request)
6
+ rack_method = :"#{@rule}?"
7
+ request.send(rack_method)
8
+ end
9
+
10
+ def identifier
11
+ "meth-#{@rule}"
12
+ end
13
+ end
14
+
15
+ end; end
@@ -0,0 +1,15 @@
1
+ module Rack; module Throttle
2
+ ###
3
+ # UrlMatchers take Regexp objects and compare the request path against them
4
+ class UrlMatcher < Matcher
5
+ def match?(request)
6
+ !!(@rule =~ request.path)
7
+ end
8
+
9
+ def identifier
10
+ "url" + @rule.inspect
11
+ end
12
+ end
13
+
14
+ end; end
15
+