railslove-rack-throttle 0.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/Rakefile ADDED
@@ -0,0 +1,47 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+
4
+ begin
5
+ require 'jeweler'
6
+ Jeweler::Tasks.new do |gem|
7
+ gem.name = "railslove-rack-throttle"
8
+ gem.summary = %Q{Extension of rack-throttle - HTTP request rate limiter for Rack applications.}
9
+ gem.description = %Q{Rack middleware for rate-limiting incoming HTTP requests.}
10
+ gem.email = "reddavis@gmail.com"
11
+ gem.homepage = "http://github.com/railslove/rack-throttle"
12
+ gem.authors = ["Arto Bendiken", "Brendon Murphy", "reddavis"]
13
+ gem.add_development_dependency "rspec", ">= 1.2.9"
14
+ gem.add_development_dependency "rack-test", ">= 0.5.3"
15
+ gem.add_runtime_dependency "rack", ">= 1.0.0"
16
+ # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
17
+ end
18
+ Jeweler::GemcutterTasks.new
19
+ rescue LoadError
20
+ puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
21
+ end
22
+
23
+ require 'spec/rake/spectask'
24
+ Spec::Rake::SpecTask.new(:spec) do |spec|
25
+ spec.libs << 'lib' << 'spec'
26
+ spec.spec_files = FileList['spec/**/*_spec.rb']
27
+ end
28
+
29
+ Spec::Rake::SpecTask.new(:rcov) do |spec|
30
+ spec.libs << 'lib' << 'spec'
31
+ spec.pattern = 'spec/**/*_spec.rb'
32
+ spec.rcov = true
33
+ end
34
+
35
+ task :spec => :check_dependencies
36
+
37
+ task :default => :spec
38
+
39
+ require 'rake/rdoctask'
40
+ Rake::RDocTask.new do |rdoc|
41
+ version = File.exist?('VERSION') ? File.read('VERSION') : ""
42
+
43
+ rdoc.rdoc_dir = 'rdoc'
44
+ rdoc.title = "Railslove Rack-Throttle #{version}"
45
+ rdoc.rdoc_files.include('README*')
46
+ rdoc.rdoc_files.include('lib/**/*.rb')
47
+ end
data/UNLICENSE ADDED
@@ -0,0 +1,24 @@
1
+ This is free and unencumbered software released into the public domain.
2
+
3
+ Anyone is free to copy, modify, publish, use, compile, sell, or
4
+ distribute this software, either in source code form or as a compiled
5
+ binary, for any purpose, commercial or non-commercial, and by any
6
+ means.
7
+
8
+ In jurisdictions that recognize copyright laws, the author or authors
9
+ of this software dedicate any and all copyright interest in the
10
+ software to the public domain. We make this dedication for the benefit
11
+ of the public at large and to the detriment of our heirs and
12
+ successors. We intend this dedication to be an overt act of
13
+ relinquishment in perpetuity of all present and future rights to this
14
+ software under copyright law.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19
+ IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ For more information, please refer to <http://unlicense.org/>
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.0.0
data/doc/.gitignore ADDED
@@ -0,0 +1,2 @@
1
+ rdoc
2
+ yard
data/etc/gdbm.ru ADDED
@@ -0,0 +1,7 @@
1
+ $:.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
2
+ require 'rack/throttle'
3
+ require 'gdbm'
4
+
5
+ use Rack::Throttle::Interval, :min => 3.0, :cache => GDBM.new('/tmp/throttle.db')
6
+
7
+ run lambda { |env| [200, {'Content-Type' => 'text/plain'}, "Hello, world!\n"] }
data/etc/hash.ru ADDED
@@ -0,0 +1,6 @@
1
+ $:.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
2
+ require 'rack/throttle'
3
+
4
+ use Rack::Throttle::Interval, :min => 3.0, :cache => {}
5
+
6
+ run lambda { |env| [200, {'Content-Type' => 'text/plain'}, "Hello, world!\n"] }
@@ -0,0 +1,8 @@
1
+ $:.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
2
+ require 'rack/throttle'
3
+ gem 'memcache-client'
4
+ require 'memcache'
5
+
6
+ use Rack::Throttle::Interval, :min => 3.0, :cache => MemCache.new('localhost:11211')
7
+
8
+ run lambda { |env| [200, {'Content-Type' => 'text/plain'}, "Hello, world!\n"] }
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,13 @@
1
+ require 'rack'
2
+
3
+ module Rack
4
+ module Throttle
5
+ autoload :Limiter, 'rack/throttle/limiter'
6
+ autoload :Interval, 'rack/throttle/interval'
7
+ autoload :TimeWindow, 'rack/throttle/time_window'
8
+ autoload :Daily, 'rack/throttle/daily'
9
+ autoload :Hourly, 'rack/throttle/hourly'
10
+ autoload :VERSION, 'rack/throttle/version'
11
+ autoload :PerMinute, 'rack/throttle/per_minute'
12
+ end
13
+ 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 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 => e
37
+ # If an error occurred while trying to update the timestamp stored
38
+ # in the cache, we will fall back to allowing the request through.
39
+ # This prevents the Rack application blowing up merely due to a
40
+ # backend cache server (Memcached, Redis, etc.) being offline.
41
+ allowed = true
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,214 @@
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
14
+ attr_reader :options
15
+
16
+ ##
17
+ # @param [#call] app
18
+ # @param [Hash{Symbol => Object}] options
19
+ # @option options [String] :cache (Hash.new)
20
+ # @option options [String] :key (nil)
21
+ # @option options [String] :key_prefix (nil)
22
+ # @option options [Integer] :code (403)
23
+ # @option options [String] :message ("Rate Limit Exceeded")
24
+ # @option options [Proc] :on_reject (Proc.new { puts "hey!" })
25
+ def initialize(app, options = {})
26
+ @app, @options = app, options
27
+ end
28
+
29
+ ##
30
+ # @param [Hash{String => String}] env
31
+ # @return [Array(Integer, Hash, #each)]
32
+ # @see http://rack.rubyforge.org/doc/SPEC.html
33
+ def call(env)
34
+ request = Rack::Request.new(env)
35
+ if allowed?(request)
36
+ app.call(env)
37
+ else
38
+ call_on_reject
39
+ rate_limit_exceeded
40
+ end
41
+ end
42
+
43
+ ##
44
+ # Returns `false` if the rate limit has been exceeded for the given
45
+ # `request`, or `true` otherwise.
46
+ #
47
+ # Override this method in subclasses that implement custom rate limiter
48
+ # strategies.
49
+ #
50
+ # @param [Rack::Request] request
51
+ # @return [Boolean]
52
+ def allowed?(request)
53
+ case
54
+ when whitelisted?(request) then true
55
+ when blacklisted?(request) then false
56
+ else true # override in subclasses
57
+ end
58
+ end
59
+
60
+ ##
61
+ # Returns `true` if the originator of the given `request` is whitelisted
62
+ # (not subject to further rate limits).
63
+ #
64
+ # The default implementation always returns `false`. Override this
65
+ # method in a subclass to implement custom whitelisting logic.
66
+ #
67
+ # @param [Rack::Request] request
68
+ # @return [Boolean]
69
+ # @abstract
70
+ def whitelisted?(request)
71
+ false
72
+ end
73
+
74
+ ##
75
+ # Returns `true` if the originator of the given `request` is blacklisted
76
+ # (not honoring rate limits, and thus permanently forbidden access
77
+ # without the need to maintain further rate limit counters).
78
+ #
79
+ # The default implementation always returns `false`. Override this
80
+ # method in a subclass to implement custom blacklisting logic.
81
+ #
82
+ # @param [Rack::Request] request
83
+ # @return [Boolean]
84
+ # @abstract
85
+ def blacklisted?(request)
86
+ false
87
+ end
88
+
89
+ protected
90
+
91
+ # Calls whatever object is passed with options[:on_reject] on initialize
92
+ def call_on_reject
93
+ @options[:on_reject].call if @options[:on_reject]
94
+ end
95
+
96
+ ##
97
+ # @return [Hash]
98
+ def cache
99
+ case cache = (options[:cache] ||= {})
100
+ when Proc then cache.call
101
+ else cache
102
+ end
103
+ end
104
+
105
+ ##
106
+ # @param [String] key
107
+ def cache_has?(key)
108
+ case
109
+ when cache.respond_to?(:has_key?)
110
+ cache.has_key?(key)
111
+ when cache.respond_to?(:get)
112
+ cache.get(key) rescue false
113
+ else false
114
+ end
115
+ end
116
+
117
+ ##
118
+ # @param [String] key
119
+ # @return [Object]
120
+ def cache_get(key, default = nil)
121
+ case
122
+ when cache.respond_to?(:[])
123
+ cache[key] || default
124
+ when cache.respond_to?(:get)
125
+ cache.get(key) || default
126
+ end
127
+ end
128
+
129
+ ##
130
+ # @param [String] key
131
+ # @param [Object] value
132
+ # @return [void]
133
+ def cache_set(key, value)
134
+ case
135
+ when cache.respond_to?(:[]=)
136
+ begin
137
+ cache[key] = value
138
+ rescue TypeError => e
139
+ # GDBM throws a "TypeError: can't convert Float into String"
140
+ # exception when trying to store a Float. On the other hand, we
141
+ # don't want to unnecessarily coerce the value to a String for
142
+ # any stores that do support other data types (e.g. in-memory
143
+ # hash objects). So, this is a compromise.
144
+ cache[key] = value.to_s
145
+ end
146
+ when cache.respond_to?(:set)
147
+ cache.set(key, value)
148
+ end
149
+ end
150
+
151
+ ##
152
+ # @param [Rack::Request] request
153
+ # @return [String]
154
+ def cache_key(request)
155
+ id = client_identifier(request)
156
+ case
157
+ when options.has_key?(:key)
158
+ options[:key].call(request)
159
+ when options.has_key?(:key_prefix)
160
+ [options[:key_prefix], id].join(':')
161
+ else id
162
+ end
163
+ end
164
+
165
+ ##
166
+ # @param [Rack::Request] request
167
+ # @return [String]
168
+ def client_identifier(request)
169
+ request.ip.to_s
170
+ end
171
+
172
+ ##
173
+ # @param [Rack::Request] request
174
+ # @return [Float]
175
+ def request_start_time(request)
176
+ case
177
+ when request.env.has_key?('HTTP_X_REQUEST_START')
178
+ request.env['HTTP_X_REQUEST_START'].to_f / 1000
179
+ else
180
+ Time.now.to_f
181
+ end
182
+ end
183
+
184
+ ##
185
+ # Outputs a `Rate Limit Exceeded` error.
186
+ #
187
+ # @return [Array(Integer, Hash, #each)]
188
+ def rate_limit_exceeded
189
+ headers = respond_to?(:retry_after) ? {'Retry-After' => retry_after.to_f.ceil.to_s} : {}
190
+ http_error(options[:code] || 403, options[:message] || 'Rate Limit Exceeded', headers)
191
+ end
192
+
193
+ ##
194
+ # Outputs an HTTP `4xx` or `5xx` response.
195
+ #
196
+ # @param [Integer] code
197
+ # @param [String, #to_s] message
198
+ # @param [Hash{String => String}] headers
199
+ # @return [Array(Integer, Hash, #each)]
200
+ def http_error(code, message = nil, headers = {})
201
+ [code, {'Content-Type' => 'text/plain; charset=utf-8'}.merge(headers),
202
+ http_status(code) + (message.nil? ? "\n" : " (#{message})\n")]
203
+ end
204
+
205
+ ##
206
+ # Returns the standard HTTP status message for the given status `code`.
207
+ #
208
+ # @param [Integer] code
209
+ # @return [String]
210
+ def http_status(code)
211
+ [code, Rack::Utils::HTTP_STATUS_CODES[code]].join(' ')
212
+ end
213
+ end
214
+ end; end