christoph-buente-api-throttling 0.2.1
Sign up to get free protection for your applications and to get access to all the features.
- data/LICENSE +20 -0
- data/README.md +197 -0
- data/Rakefile +39 -0
- data/TODO.md +6 -0
- data/VERSION.yml +4 -0
- data/lib/api_throttling.rb +73 -0
- data/lib/handlers/active_support_cache_store_handler.rb +21 -0
- data/lib/handlers/handlers.rb +46 -0
- data/lib/handlers/hash_handler.rb +15 -0
- data/lib/handlers/memcache_handler.rb +15 -0
- data/lib/handlers/redis_handler.rb +15 -0
- data/test/test_api_throttling.rb +221 -0
- data/test/test_api_throttling_hash.rb +23 -0
- data/test/test_api_throttling_memcache.rb +24 -0
- data/test/test_handlers.rb +25 -0
- data/test/test_helper.rb +54 -0
- metadata +84 -0
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2009 Luc Castera
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,197 @@
|
|
1
|
+
# Rack Middleware for Api Throttling
|
2
|
+
|
3
|
+
<p>I will show you a technique to impose a rate limit (aka API Throttling) on a Ruby Web Service. I will be using Rack middleware so you can use this no matter what Ruby Web Framework you are using, as long as it is Rack-compliant.</p>
|
4
|
+
|
5
|
+
<h2>Installation</h2>
|
6
|
+
<p>This middleware has recently been gemmified, you can install the latest gem using:</p>
|
7
|
+
<pre>sudo gem install jduff-api-throttling</pre>
|
8
|
+
<p>If you prefer to have the latest source it can be found at http://github.com/jduff/api-throttling/tree (this is a fork of http://github.com/dambalah/api-throttling/tree with a number of recent changes)</p>
|
9
|
+
|
10
|
+
<h2>Usage</h2>
|
11
|
+
<p>In your rack application simply use the middleware and pass it some options</p>
|
12
|
+
<pre>use ApiThrottling, :requests_per_hour => 3</pre>
|
13
|
+
<p>This will setup throttling with a limit of 3 requests per hour and will use a Redis cache to keep track of it. By default Rack::Auth::Basic is used to limit the requests on a per user basis.</p>
|
14
|
+
<p>A number of options can be passed to the middleware so it can be configured as needed for your stack.</p>
|
15
|
+
<pre>:cache=>:redis # :memcache, :hash are supported. you can also pass in an instance of those caches, or even Rails.cache</pre>
|
16
|
+
<pre>:auth=>false # if your middleware is doing authentication somewhere else</pre>
|
17
|
+
<pre>:key=>Proc.new{|env,auth| "#{env['PATH_INFO']}_#{Time.now.strftime("%Y-%m-%d-%H")}" } # to customize how the cache key is generated</pre>
|
18
|
+
|
19
|
+
<p>An example using all the options might look something like this:</p>
|
20
|
+
<pre>
|
21
|
+
CACHE = MemCache.new
|
22
|
+
use ApiThrottling, :requests_per_hour => 100, :cache=>CACHE, :auth=>false,
|
23
|
+
:key=>Proc.new{|env,auth| "#{env['PATH_INFO']}_#{Time.now.strftime("%Y-%m-%d-%H")}" }
|
24
|
+
</pre>
|
25
|
+
<p>This will limit requests to 100 per hour per url ('/home' will be tracked separately from '/users') keeping track by storing the counts with MemCache.</p>
|
26
|
+
|
27
|
+
<h2>Introduction to Rack</h2>
|
28
|
+
|
29
|
+
<p>There are plenty of <a href="http://jasonseifer.com/2009/04/08/32-rack-resources-to-get-you-started">great resources</a> to learn the basic of Rack so I will not be explaining how Rack works here but you will need to understand it in order to follow this post. I highly recommend watching the <a href="http://remi.org/2009/02/19/rack-basics.html">three</a> <a href="http://remi.org/2009/02/24/rack-part-2.html">Rack</a> <a href="http://remi.org/2009/02/28/rack-part-3-middleware.html">screencasts</a> from <a href="http://remi.org/">Remi</a> to get started with Rack.</p>
|
30
|
+
|
31
|
+
<h2>Basic Rack Application</h2>
|
32
|
+
|
33
|
+
<p>First, make sure you have the <a href="http://code.macournoyer.com/thin/">thin webserver</a> installed.</p>
|
34
|
+
|
35
|
+
<pre>sudo gem install thin</pre>
|
36
|
+
|
37
|
+
<p>We are going to use the following 'Hello World' Rack application to test our API Throttling middleware.</p>
|
38
|
+
|
39
|
+
<pre>
|
40
|
+
use Rack::ShowExceptions
|
41
|
+
use Rack::Lint
|
42
|
+
|
43
|
+
run lambda {|env| [200, { 'Content-Type' => 'text/plain', 'Content-Length' => '12'}, ["Hello World!"] ] }
|
44
|
+
</pre>
|
45
|
+
|
46
|
+
|
47
|
+
<p>Save this code in a file called <em>config.ru</em> and then you can run it with the thin webserver, using the following command:</p>
|
48
|
+
|
49
|
+
<pre>thin --rackup config.ru start</pre>
|
50
|
+
|
51
|
+
<p>Now you can open another terminal window (or a browser) to test that this is working as expected:</p>
|
52
|
+
|
53
|
+
<pre>curl -i http://localhost:3000</pre>
|
54
|
+
|
55
|
+
<p>The -i option tells curl to include the HTTP-header in the output so you should see the following:</p>
|
56
|
+
|
57
|
+
<pre>
|
58
|
+
$ curl -i http://localhost:3000
|
59
|
+
HTTP/1.1 200 OK
|
60
|
+
Content-Type: text/plain
|
61
|
+
Content-Length: 12
|
62
|
+
Connection: keep-alive
|
63
|
+
Server: thin 1.0.0 codename That's What She Said
|
64
|
+
|
65
|
+
Hello World!
|
66
|
+
</pre>
|
67
|
+
|
68
|
+
<p>At this point, we have a basic rack application that we can use to test our rack middleware. Now let's get started.</p>
|
69
|
+
|
70
|
+
|
71
|
+
<h2>Redis</h2>
|
72
|
+
|
73
|
+
<p>We need a way to memorize the number of requests that users are making to our web service if we want to limit the rate at which they can use the API. Every time they make a request, we want to check if they've gone past their rate limit before we respond to the request. We also want to store the fact that they've just made a request. Since every call to our web service requires this check and memorization process, we would like this to be done as fast as possible.</p>
|
74
|
+
|
75
|
+
<p>This is where Redis comes in. Redis is a super-fast key-value database that we've highlighted <a href="http://blog.messagepub.com/2009/04/20/project-spotlight-redis-a-fast-data-structure-database/">in a previous blog post</a>. It can do about 110,000 SETs per second, about 81,000 GETs per second. That's the kind of performance that we are looking for since we would not like our 'rate limiting' middleware to reduce the performance of our web service.</p>
|
76
|
+
|
77
|
+
<p>Install the redis ruby client library with <pre>sudo gem install ezmobius-redis-rb</pre></p>
|
78
|
+
|
79
|
+
|
80
|
+
<h2>Our Rack Middleware</h2>
|
81
|
+
|
82
|
+
<p>We are assuming that the web service is using HTTP Basic Authentication. You could use another type of authentication and adapt the code to fit your model.</p>
|
83
|
+
|
84
|
+
<p>Our rack middleware will do the following:</p>
|
85
|
+
<ul>
|
86
|
+
<li>For every request received, increment a key in our database. The key string will consists of the authenticated username followed by a timestamp for the current hour. For example, for a user called joe, the key would be: <em><strong>joe_2009-05-01-12</em></strong></li>
|
87
|
+
<li>If the value of that key is less than our 'maximum requests per hour limit', then return an HTTP Response with a status code of 503, indicating that the user has gone over his rate limit.</li>
|
88
|
+
<li>If the value of the key is less than the maximum requests per hour limit, then allow the user's request to go through.</li>
|
89
|
+
</ul>
|
90
|
+
|
91
|
+
<p>Redis has an atomic <a href="http://code.google.com/p/redis/wiki/IncrCommand">INCR command</a> that is the perfect fit for our use case. It increments the key value by one. If the key does not exist, it sets the key to the value of "0" and then increments it. Awesome! We don't even need to write our own logic to check if the key exists before incrementing it, Redis takes care of that for us.</p>
|
92
|
+
|
93
|
+
<pre>
|
94
|
+
r = Redis.new
|
95
|
+
key = "#{auth.username}_#{Time.now.strftime("%Y-%m-%d-%H")}"
|
96
|
+
r.incr(key)
|
97
|
+
return over_rate_limit if r[key].to_i > @options[:requests_per_hour]
|
98
|
+
</pre>
|
99
|
+
|
100
|
+
<p>If our redis-server is not running, rather than throwing an error affecting all our users, we will let all the requests pass through by catching the exception and doing nothing. That means that if your redis-server goes down, you are no longer throttling the use of your web service so you need to make sure it's always running (using <a href="http://mmonit.com/monit/">monit</a> or <a href="http://god.rubyforge.org/">god</a>, for example).</p>
|
101
|
+
|
102
|
+
<p>Finally, we want anyone who might use this Rack middleware to be able to set their limit via the <em>requests_per_hour</em> option.</p>
|
103
|
+
|
104
|
+
<p>The full code for our middleware is below. You can also find it at <a href="https://github.com/dambalah/api-throttling">github.com/dambalah/api-throttling</a>.</p>
|
105
|
+
|
106
|
+
<pre>
|
107
|
+
require 'rubygems'
|
108
|
+
require 'rack'
|
109
|
+
require 'redis'
|
110
|
+
|
111
|
+
class ApiThrottling
|
112
|
+
def initialize(app, options={})
|
113
|
+
@app = app
|
114
|
+
@options = {:requests_per_hour => 60}.merge(options)
|
115
|
+
end
|
116
|
+
|
117
|
+
def call(env, options={})
|
118
|
+
auth = Rack::Auth::Basic::Request.new(env)
|
119
|
+
if auth.provided?
|
120
|
+
return bad_request unless auth.basic?
|
121
|
+
begin
|
122
|
+
r = Redis.new
|
123
|
+
key = "#{auth.username}_#{Time.now.strftime("%Y-%m-%d-%H")}"
|
124
|
+
r.incr(key)
|
125
|
+
return over_rate_limit if r[key].to_i > @options[:requests_per_hour]
|
126
|
+
rescue Errno::ECONNREFUSED
|
127
|
+
# If Redis-server is not running, instead of throwing an error, we simply do not throttle the API
|
128
|
+
# It's better if your service is up and running but not throttling API, then to have it throw errors for all users
|
129
|
+
# Make sure you monitor your redis-server so that it's never down. monit is a great tool for that.
|
130
|
+
end
|
131
|
+
end
|
132
|
+
@app.call(env)
|
133
|
+
end
|
134
|
+
|
135
|
+
def bad_request
|
136
|
+
body_text = "Bad Request"
|
137
|
+
[ 400, { 'Content-Type' => 'text/plain', 'Content-Length' => body_text.size.to_s }, [body_text] ]
|
138
|
+
end
|
139
|
+
|
140
|
+
def over_rate_limit
|
141
|
+
body_text = "Over Rate Limit"
|
142
|
+
[ 503, { 'Content-Type' => 'text/plain', 'Content-Length' => body_text.size.to_s }, [body_text] ]
|
143
|
+
end
|
144
|
+
end
|
145
|
+
</pre>
|
146
|
+
|
147
|
+
<p>To use it on our 'Hello World' rack application, simply add it with the <em>use</em> keyword and the <em>:requests_per_hour</em> option:</p>
|
148
|
+
|
149
|
+
<pre>
|
150
|
+
require 'api_throttling'
|
151
|
+
|
152
|
+
use Rack::Lint
|
153
|
+
use Rack::ShowExceptions
|
154
|
+
use ApiThrottling, :requests_per_hour => 3
|
155
|
+
|
156
|
+
run lambda {|env| [200, {'Content-Type' => 'text/plain', 'Content-Length' => '12'}, ["Hello World!"] ] }
|
157
|
+
</pre>
|
158
|
+
|
159
|
+
<p><strong>That's it!</strong> Make sure your <em>redis-server</em> is running on port 6379 and try making calls to your api with curl. The first 3 calls will be succesful but the next ones will block because you've reached the limit that we've set:</p>
|
160
|
+
|
161
|
+
<pre>
|
162
|
+
$ curl -i http://joe@localhost:3000
|
163
|
+
HTTP/1.1 200 OK
|
164
|
+
Content-Type: text/plain
|
165
|
+
Content-Length: 12
|
166
|
+
Connection: keep-alive
|
167
|
+
Server: thin 1.0.0 codename That's What She Said
|
168
|
+
|
169
|
+
Hello World!
|
170
|
+
|
171
|
+
$ curl -i http://joe@localhost:3000
|
172
|
+
HTTP/1.1 200 OK
|
173
|
+
Content-Type: text/plain
|
174
|
+
Content-Length: 12
|
175
|
+
Connection: keep-alive
|
176
|
+
Server: thin 1.0.0 codename That's What She Said
|
177
|
+
|
178
|
+
Hello World!
|
179
|
+
|
180
|
+
$ curl -i http://joe@localhost:3000
|
181
|
+
HTTP/1.1 200 OK
|
182
|
+
Content-Type: text/plain
|
183
|
+
Content-Length: 12
|
184
|
+
Connection: keep-alive
|
185
|
+
Server: thin 1.0.0 codename That's What She Said
|
186
|
+
|
187
|
+
Hello World!
|
188
|
+
|
189
|
+
$ curl -i http://joe@localhost:3000
|
190
|
+
HTTP/1.1 503 Service Unavailable
|
191
|
+
Content-Type: text/plain
|
192
|
+
Content-Length: 15
|
193
|
+
Connection: keep-alive
|
194
|
+
Server: thin 1.0.0 codename That's What She Said
|
195
|
+
|
196
|
+
Over Rate Limit
|
197
|
+
</pre>
|
data/Rakefile
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake'
|
3
|
+
|
4
|
+
begin
|
5
|
+
require 'jeweler'
|
6
|
+
Jeweler::Tasks.new do |gemspec|
|
7
|
+
gemspec.name = "api-throttling"
|
8
|
+
gemspec.summary = "Rack Middleware to impose a rate limit on a web service (aka API Throttling)"
|
9
|
+
gemspec.email = "duff.john@gmail.com"
|
10
|
+
gemspec.homepage = "http://github.com/jduff/api-throttling/tree"
|
11
|
+
gemspec.description = "TODO"
|
12
|
+
gemspec.authors = ["Luc Castera", "John Duff"]
|
13
|
+
gemspec.add_development_dependency('context')
|
14
|
+
end
|
15
|
+
rescue LoadError
|
16
|
+
puts "Jeweler not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
|
17
|
+
end
|
18
|
+
|
19
|
+
require 'rake/testtask'
|
20
|
+
Rake::TestTask.new(:test) do |test|
|
21
|
+
test.libs << 'lib' << 'test'
|
22
|
+
test.pattern = 'test/**/test_*.rb'
|
23
|
+
test.verbose = true
|
24
|
+
end
|
25
|
+
|
26
|
+
begin
|
27
|
+
require 'rcov/rcovtask'
|
28
|
+
Rcov::RcovTask.new do |test|
|
29
|
+
test.libs << 'test'
|
30
|
+
test.pattern = 'test/**/*_test.rb'
|
31
|
+
test.verbose = true
|
32
|
+
end
|
33
|
+
rescue LoadError
|
34
|
+
task :rcov do
|
35
|
+
abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
task :default => :test
|
data/VERSION.yml
ADDED
@@ -0,0 +1,73 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rack'
|
3
|
+
require File.expand_path(File.dirname(__FILE__) + '/handlers/handlers')
|
4
|
+
|
5
|
+
class ApiThrottling
|
6
|
+
def initialize(app, options={})
|
7
|
+
@app = app
|
8
|
+
@options = {:requests_per_hour => 60, :cache=>:redis, :auth=>true}.merge(options)
|
9
|
+
@handler = Handlers.cache_handler_for(@options[:cache])
|
10
|
+
raise "Sorry, we couldn't find a handler for the cache you specified: #{@options[:cache]}" unless @handler
|
11
|
+
end
|
12
|
+
|
13
|
+
def call(env, options={})
|
14
|
+
if @options[:only] or @options[:except]
|
15
|
+
req = Rack::Request.new(env)
|
16
|
+
# call the app normally cause the path restriction didn't match
|
17
|
+
return @app.call(env) unless path_matches?(req.path)
|
18
|
+
end
|
19
|
+
|
20
|
+
if @options[:auth]
|
21
|
+
auth = Rack::Auth::Basic::Request.new(env)
|
22
|
+
return auth_required unless auth.provided?
|
23
|
+
return bad_request unless auth.basic?
|
24
|
+
end
|
25
|
+
|
26
|
+
begin
|
27
|
+
cache = @handler.new(@options[:cache])
|
28
|
+
key = generate_key(env, auth)
|
29
|
+
cache.increment(key)
|
30
|
+
return over_rate_limit if cache.get(key).to_i > @options[:requests_per_hour]
|
31
|
+
rescue Errno::ECONNREFUSED
|
32
|
+
# If Redis-server is not running, instead of throwing an error, we simply do not throttle the API
|
33
|
+
# It's better if your service is up and running but not throttling API, then to have it throw errors for all users
|
34
|
+
# Make sure you monitor your redis-server so that it's never down. monit is a great tool for that.
|
35
|
+
end
|
36
|
+
@app.call(env)
|
37
|
+
end
|
38
|
+
|
39
|
+
def path_matches?(path)
|
40
|
+
only = @options[:only] || ''
|
41
|
+
except = @options[:except] || ' '
|
42
|
+
(path =~ /^#{only}/) and !(path =~ /^#{except}/)
|
43
|
+
end
|
44
|
+
|
45
|
+
def generate_key(env, auth)
|
46
|
+
return @options[:key].call(env, auth) if @options[:key]
|
47
|
+
auth ? "#{auth.username}_#{Time.now.strftime("%Y-%m-%d-%H")}" : "#{Time.now.strftime("%Y-%m-%d-%H")}"
|
48
|
+
end
|
49
|
+
|
50
|
+
def bad_request
|
51
|
+
body_text = "Bad Request"
|
52
|
+
[ 400, { 'Content-Type' => 'text/plain', 'Content-Length' => body_text.size.to_s }, [body_text] ]
|
53
|
+
end
|
54
|
+
|
55
|
+
def auth_required
|
56
|
+
body_text = "Authorization Required"
|
57
|
+
[ 401, { 'Content-Type' => 'text/plain', 'Content-Length' => body_text.size.to_s }, [body_text] ]
|
58
|
+
end
|
59
|
+
|
60
|
+
def over_rate_limit
|
61
|
+
body_text = "Over Rate Limit"
|
62
|
+
retry_after_in_seconds = (60 - Time.now.min) * 60
|
63
|
+
[ 503,
|
64
|
+
{ 'Content-Type' => 'text/plain',
|
65
|
+
'Content-Length' => body_text.size.to_s,
|
66
|
+
'Retry-After' => retry_after_in_seconds.to_s
|
67
|
+
},
|
68
|
+
[body_text]
|
69
|
+
]
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module Handlers
|
2
|
+
class ActiveSupportCacheStoreHandler < Handler
|
3
|
+
|
4
|
+
def initialize(object=nil)
|
5
|
+
raise "Must provide an existing ActiveSupport::Cache::Store" unless object.is_a?(ActiveSupport::Cache::Store)
|
6
|
+
@cache = object
|
7
|
+
end
|
8
|
+
|
9
|
+
def increment(key)
|
10
|
+
@cache.write(key, (get(key)||0).to_i+1)
|
11
|
+
end
|
12
|
+
|
13
|
+
def get(key)
|
14
|
+
@cache.read(key)
|
15
|
+
end
|
16
|
+
|
17
|
+
%w(MemCacheStore FileStore MemoryStore SynchronizedMemoryStore DRbStore CompressedMemCacheStore).each do |store|
|
18
|
+
Handlers.add_handler(self, "ActiveSupport::Cache::#{store}".downcase)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module Handlers
|
2
|
+
HANDLERS = {}
|
3
|
+
|
4
|
+
def self.cache_handler_for(info)
|
5
|
+
HANDLERS[info.to_s.downcase] || HANDLERS[info.class.to_s.downcase]
|
6
|
+
end
|
7
|
+
|
8
|
+
def self.add_handler(handler, key=nil)
|
9
|
+
HANDLERS[key || handler.cache_class.downcase] = handler
|
10
|
+
end
|
11
|
+
|
12
|
+
# creating a new cache handler is as simple as extending from the handler class,
|
13
|
+
# setting the class to use as the cache by calling cache_class("Redis")
|
14
|
+
# and then implementing the increment and get methods for that cache type.
|
15
|
+
#
|
16
|
+
# If you don't want to extend from Handler you can just create a class that implements
|
17
|
+
# increment(key), get(key) and handles?(info)
|
18
|
+
#
|
19
|
+
# you can then initialize the middleware and pass :cache=>CACHE_NAME as an option.
|
20
|
+
class Handler
|
21
|
+
def initialize(object=nil)
|
22
|
+
cache = Object.const_get(self.class.cache_class)
|
23
|
+
@cache = object.is_a?(cache) ? object : cache.new
|
24
|
+
end
|
25
|
+
|
26
|
+
def increment(key)
|
27
|
+
raise "Cache Handlers must implement an increment method"
|
28
|
+
end
|
29
|
+
|
30
|
+
def get(key)
|
31
|
+
raise "Cache Handlers must implement a get method"
|
32
|
+
end
|
33
|
+
|
34
|
+
class << self
|
35
|
+
|
36
|
+
def cache_class(name = nil)
|
37
|
+
@cache_class = name if name
|
38
|
+
@cache_class
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
%w(redis_handler memcache_handler hash_handler active_support_cache_store_handler).each do |handler|
|
44
|
+
require File.expand_path(File.dirname(__FILE__) + "/#{handler}")
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,221 @@
|
|
1
|
+
require 'redis'
|
2
|
+
require File.expand_path(File.dirname(__FILE__) + '/test_helper')
|
3
|
+
|
4
|
+
# To Run this test, you need to have the redis-server running.
|
5
|
+
# And you need to have rack-test gem installed: sudo gem install rack-test
|
6
|
+
# For more information on rack-test, visit: http://github.com/brynary/rack-test
|
7
|
+
|
8
|
+
class ApiThrottlingTest < Test::Unit::TestCase
|
9
|
+
include Rack::Test::Methods
|
10
|
+
|
11
|
+
context "using redis" do
|
12
|
+
before do
|
13
|
+
# Delete all the keys for 'joe' in Redis so that every test starts fresh
|
14
|
+
# Having this here also helps as a reminder to start redis-server
|
15
|
+
begin
|
16
|
+
r = Redis.new
|
17
|
+
r.keys("*").each do |key|
|
18
|
+
r.delete key
|
19
|
+
end
|
20
|
+
|
21
|
+
rescue Errno::ECONNREFUSED
|
22
|
+
assert false, "You need to start redis-server"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
context "with authentication required" do
|
27
|
+
include BasicTests
|
28
|
+
|
29
|
+
def app
|
30
|
+
app = Rack::Builder.new {
|
31
|
+
use ApiThrottling, :requests_per_hour => 3
|
32
|
+
run lambda {|env| [200, {'Content-Type' => 'text/plain', 'Content-Length' => '12'}, ["Hello World!"] ] }
|
33
|
+
}
|
34
|
+
end
|
35
|
+
|
36
|
+
def test_cache_handler_should_be_redis
|
37
|
+
assert_equal "Handlers::RedisHandler", app.to_app.instance_variable_get(:@handler).to_s
|
38
|
+
end
|
39
|
+
|
40
|
+
end
|
41
|
+
|
42
|
+
context "without authentication required" do
|
43
|
+
def app
|
44
|
+
app = Rack::Builder.new {
|
45
|
+
use ApiThrottling, :requests_per_hour => 3, :auth=>false
|
46
|
+
run lambda {|env| [200, {'Content-Type' => 'text/plain', 'Content-Length' => '12'}, ["Hello World!"] ] }
|
47
|
+
}
|
48
|
+
end
|
49
|
+
|
50
|
+
def test_should_not_require_authorization
|
51
|
+
3.times do
|
52
|
+
get '/'
|
53
|
+
assert_equal 200, last_response.status
|
54
|
+
end
|
55
|
+
get '/'
|
56
|
+
assert_equal 503, last_response.status
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
context "with rate limit key based on url" do
|
61
|
+
def app
|
62
|
+
app = Rack::Builder.new {
|
63
|
+
use ApiThrottling, :requests_per_hour => 3,
|
64
|
+
:key=>Proc.new{ |env,auth| "#{auth.username}_#{env['PATH_INFO']}_#{Time.now.strftime("%Y-%m-%d-%H")}" }
|
65
|
+
run lambda {|env| [200, {'Content-Type' => 'text/plain', 'Content-Length' => '12'}, ["Hello World!"] ] }
|
66
|
+
}
|
67
|
+
end
|
68
|
+
|
69
|
+
test "should throttle requests based on the user and url called" do
|
70
|
+
authorize "joe", "secret"
|
71
|
+
3.times do
|
72
|
+
get '/'
|
73
|
+
assert_equal 200, last_response.status
|
74
|
+
end
|
75
|
+
get '/'
|
76
|
+
assert_equal 503, last_response.status
|
77
|
+
|
78
|
+
3.times do
|
79
|
+
get '/awesome'
|
80
|
+
assert_equal 200, last_response.status
|
81
|
+
end
|
82
|
+
get '/awesome'
|
83
|
+
assert_equal 503, last_response.status
|
84
|
+
|
85
|
+
authorize "luc", "secret"
|
86
|
+
get '/awesome'
|
87
|
+
assert_equal 200, last_response.status
|
88
|
+
|
89
|
+
get '/'
|
90
|
+
assert_equal 200, last_response.status
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
context "with path restriction :only" do
|
95
|
+
def app
|
96
|
+
app = Rack::Builder.new {
|
97
|
+
use ApiThrottling, :requests_per_hour => 3, :only => '/awesome'
|
98
|
+
run lambda {|env| [200, {'Content-Type' => 'text/plain', 'Content-Length' => '12'}, ["Hello World!"] ] }
|
99
|
+
}
|
100
|
+
end
|
101
|
+
|
102
|
+
test "should not throttle request to /" do
|
103
|
+
5.times do
|
104
|
+
get '/'
|
105
|
+
assert_equal 200, last_response.status
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
test "should throttle request to /awesome" do
|
110
|
+
3.times do
|
111
|
+
get '/awesome'
|
112
|
+
assert_equal 200, last_response.status
|
113
|
+
end
|
114
|
+
get '/awesome'
|
115
|
+
assert_equal 503, last_response.status
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
context "with path restrictions :except" do
|
120
|
+
def app
|
121
|
+
app = Rack::Builder.new {
|
122
|
+
use ApiThrottling, :requests_per_hour => 3, :except => '/awesome'
|
123
|
+
run lambda {|env| [200, {'Content-Type' => 'text/plain', 'Content-Length' => '12'}, ["Hello World!"] ] }
|
124
|
+
}
|
125
|
+
end
|
126
|
+
|
127
|
+
test "should not throttle request to /awesome" do
|
128
|
+
5.times do
|
129
|
+
get '/awesome'
|
130
|
+
assert_equal 200, last_response.status
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
test "should throttle request to /" do
|
135
|
+
3.times do
|
136
|
+
get '/'
|
137
|
+
assert_equal 200, last_response.status
|
138
|
+
end
|
139
|
+
get '/'
|
140
|
+
assert_equal 503, last_response.status
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
conteyt "with path rescrictions :only and :except" do
|
145
|
+
def app
|
146
|
+
app = Rack::Builder.new {
|
147
|
+
use ApiThrottling, :requests_per_hour => 3, :only => '/awesome', :except => '/awesome/foo'
|
148
|
+
run lambda {|env| [200, {'Content-Type' => 'text/plain', 'Content-Length' => '12'}, ["Hello World!"] ] }
|
149
|
+
}
|
150
|
+
end
|
151
|
+
|
152
|
+
test "should not throttle request to /" do
|
153
|
+
5.times do
|
154
|
+
get '/'
|
155
|
+
assert_equal 200, last_response.status
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
test "should throttle request to /awesome" do
|
160
|
+
3.times do
|
161
|
+
get '/awesome'
|
162
|
+
assert_equal 200, last_response.status
|
163
|
+
end
|
164
|
+
get '/awesome'
|
165
|
+
assert_equal 503, last_response.status
|
166
|
+
end
|
167
|
+
|
168
|
+
test "should not throttle request to /awesome/foo" do
|
169
|
+
5.times do
|
170
|
+
get '/awesome/foo'
|
171
|
+
assert_equal 200, last_response.status
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
context "using active support cache store" do
|
178
|
+
require 'active_support'
|
179
|
+
|
180
|
+
context "memory store" do
|
181
|
+
include BasicTests
|
182
|
+
|
183
|
+
before do
|
184
|
+
@@cache_store = ActiveSupport::Cache.lookup_store(:memory_store)
|
185
|
+
end
|
186
|
+
|
187
|
+
def app
|
188
|
+
app = Rack::Builder.new {
|
189
|
+
use ApiThrottling, :requests_per_hour => 3, :cache=>@@cache_store
|
190
|
+
run lambda {|env| [200, {'Content-Type' => 'text/plain', 'Content-Length' => '12'}, ["Hello World!"] ] }
|
191
|
+
}
|
192
|
+
end
|
193
|
+
|
194
|
+
def test_cache_handler_should_be_memcache
|
195
|
+
assert_equal "Handlers::ActiveSupportCacheStoreHandler", app.to_app.instance_variable_get(:@handler).to_s
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
context "memcache store" do
|
200
|
+
include BasicTests
|
201
|
+
|
202
|
+
|
203
|
+
before do
|
204
|
+
@@cache_store = ActiveSupport::Cache.lookup_store(:memCache_store)
|
205
|
+
@@cache_store.clear
|
206
|
+
end
|
207
|
+
|
208
|
+
def app
|
209
|
+
app = Rack::Builder.new {
|
210
|
+
use ApiThrottling, :requests_per_hour => 3, :cache=>@@cache_store
|
211
|
+
run lambda {|env| [200, {'Content-Type' => 'text/plain', 'Content-Length' => '12'}, ["Hello World!"] ] }
|
212
|
+
}
|
213
|
+
end
|
214
|
+
|
215
|
+
def test_cache_handler_should_be_memcache
|
216
|
+
assert_equal "Handlers::ActiveSupportCacheStoreHandler", app.to_app.instance_variable_get(:@handler).to_s
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/test_helper')
|
2
|
+
|
3
|
+
class TestApiThrottlingHash < Test::Unit::TestCase
|
4
|
+
include Rack::Test::Methods
|
5
|
+
include BasicTests
|
6
|
+
HASH = Hash.new
|
7
|
+
|
8
|
+
def app
|
9
|
+
app = Rack::Builder.new {
|
10
|
+
use ApiThrottling, :requests_per_hour => 3, :cache => HASH
|
11
|
+
run lambda {|env| [200, {'Content-Type' => 'text/plain', 'Content-Length' => '12'}, ["Hello World!"] ] }
|
12
|
+
}
|
13
|
+
end
|
14
|
+
|
15
|
+
def setup
|
16
|
+
HASH.replace({})
|
17
|
+
end
|
18
|
+
|
19
|
+
def test_cache_handler_should_be_memcache
|
20
|
+
assert_equal "Handlers::HashHandler", app.to_app.instance_variable_get(:@handler).to_s
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
require 'memcache'
|
2
|
+
require File.expand_path(File.dirname(__FILE__) + '/test_helper')
|
3
|
+
|
4
|
+
class TestApiThrottlingMemcache < Test::Unit::TestCase
|
5
|
+
include Rack::Test::Methods
|
6
|
+
include BasicTests
|
7
|
+
CACHE = MemCache.new 'localhost:11211', :namespace=>'api-throttling-tests'
|
8
|
+
|
9
|
+
def app
|
10
|
+
app = Rack::Builder.new {
|
11
|
+
use ApiThrottling, :requests_per_hour => 3, :cache => CACHE
|
12
|
+
run lambda {|env| [200, {'Content-Type' => 'text/plain', 'Content-Length' => '12'}, ["Hello World!"] ] }
|
13
|
+
}
|
14
|
+
end
|
15
|
+
|
16
|
+
def setup
|
17
|
+
CACHE.flush_all
|
18
|
+
end
|
19
|
+
|
20
|
+
def test_cache_handler_should_be_memcache
|
21
|
+
assert_equal "Handlers::MemCacheHandler", app.to_app.instance_variable_get(:@handler).to_s
|
22
|
+
end
|
23
|
+
|
24
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
require 'redis'
|
2
|
+
require 'memcache'
|
3
|
+
require File.expand_path(File.dirname(__FILE__) + '/test_helper')
|
4
|
+
|
5
|
+
class HandlersTest < Test::Unit::TestCase
|
6
|
+
|
7
|
+
should "select redis handler" do
|
8
|
+
[:redis, 'redis', 'Redis', Redis.new].each do |key|
|
9
|
+
assert_equal Handlers::RedisHandler, Handlers.cache_handler_for(key)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
should "select memcache handler" do
|
14
|
+
[:memcache, 'memcache', 'MemCache', MemCache.new].each do |key|
|
15
|
+
assert_equal Handlers::MemCacheHandler, Handlers.cache_handler_for(key)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
should "select hash handler" do
|
20
|
+
[:hash, 'hash', 'Hash', {}].each do |key|
|
21
|
+
assert_equal Handlers::HashHandler, Handlers.cache_handler_for(key)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
data/test/test_helper.rb
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rack/test'
|
3
|
+
require 'test/unit'
|
4
|
+
require 'context'
|
5
|
+
require File.expand_path(File.dirname(__FILE__) + '/../lib/api_throttling')
|
6
|
+
|
7
|
+
# this way we can include the module for any of the handler tests
|
8
|
+
module BasicTests
|
9
|
+
def test_first_request_should_return_hello_world
|
10
|
+
authorize "joe", "secret"
|
11
|
+
get '/'
|
12
|
+
assert_equal 200, last_response.status
|
13
|
+
assert_equal "Hello World!", last_response.body
|
14
|
+
end
|
15
|
+
|
16
|
+
def test_fourth_request_should_be_blocked
|
17
|
+
authorize "joe", "secret"
|
18
|
+
3.times do
|
19
|
+
get '/'
|
20
|
+
assert_equal 200, last_response.status
|
21
|
+
end
|
22
|
+
get '/'
|
23
|
+
assert_equal 503, last_response.status
|
24
|
+
end
|
25
|
+
|
26
|
+
def test_over_rate_limit_should_only_apply_to_user_that_went_over_the_limit
|
27
|
+
authorize "joe", "secret"
|
28
|
+
5.times { get '/' }
|
29
|
+
assert_equal 503, last_response.status
|
30
|
+
authorize "luc", "secret"
|
31
|
+
get '/'
|
32
|
+
assert_equal 200, last_response.status
|
33
|
+
end
|
34
|
+
|
35
|
+
def test_over_rate_limit_should_return_a_retry_after_header
|
36
|
+
authorize "joe", "secret"
|
37
|
+
4.times { get '/' }
|
38
|
+
assert_equal 503, last_response.status
|
39
|
+
assert_not_nil last_response.headers['Retry-After']
|
40
|
+
end
|
41
|
+
|
42
|
+
def test_retry_after_should_be_less_than_60_minutes
|
43
|
+
authorize "joe", "secret"
|
44
|
+
4.times { get '/' }
|
45
|
+
assert_equal 503, last_response.status
|
46
|
+
assert last_response.headers['Retry-After'].to_i <= (60 * 60)
|
47
|
+
end
|
48
|
+
|
49
|
+
def test_should_require_authorization
|
50
|
+
get '/'
|
51
|
+
assert_equal 401, last_response.status
|
52
|
+
end
|
53
|
+
|
54
|
+
end
|
metadata
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: christoph-buente-api-throttling
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.2.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Luc Castera
|
8
|
+
- John Duff
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
|
13
|
+
date: 2009-07-06 00:00:00 -07:00
|
14
|
+
default_executable:
|
15
|
+
dependencies:
|
16
|
+
- !ruby/object:Gem::Dependency
|
17
|
+
name: context
|
18
|
+
type: :development
|
19
|
+
version_requirement:
|
20
|
+
version_requirements: !ruby/object:Gem::Requirement
|
21
|
+
requirements:
|
22
|
+
- - ">="
|
23
|
+
- !ruby/object:Gem::Version
|
24
|
+
version: "0"
|
25
|
+
version:
|
26
|
+
description: TODO
|
27
|
+
email: duff.john@gmail.com
|
28
|
+
executables: []
|
29
|
+
|
30
|
+
extensions: []
|
31
|
+
|
32
|
+
extra_rdoc_files:
|
33
|
+
- LICENSE
|
34
|
+
- README.md
|
35
|
+
files:
|
36
|
+
- LICENSE
|
37
|
+
- README.md
|
38
|
+
- Rakefile
|
39
|
+
- TODO.md
|
40
|
+
- VERSION.yml
|
41
|
+
- lib/api_throttling.rb
|
42
|
+
- lib/handlers/active_support_cache_store_handler.rb
|
43
|
+
- lib/handlers/handlers.rb
|
44
|
+
- lib/handlers/hash_handler.rb
|
45
|
+
- lib/handlers/memcache_handler.rb
|
46
|
+
- lib/handlers/redis_handler.rb
|
47
|
+
- test/test_api_throttling.rb
|
48
|
+
- test/test_api_throttling_hash.rb
|
49
|
+
- test/test_api_throttling_memcache.rb
|
50
|
+
- test/test_handlers.rb
|
51
|
+
- test/test_helper.rb
|
52
|
+
has_rdoc: true
|
53
|
+
homepage: http://github.com/jduff/api-throttling/tree
|
54
|
+
licenses:
|
55
|
+
post_install_message:
|
56
|
+
rdoc_options:
|
57
|
+
- --charset=UTF-8
|
58
|
+
require_paths:
|
59
|
+
- lib
|
60
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
61
|
+
requirements:
|
62
|
+
- - ">="
|
63
|
+
- !ruby/object:Gem::Version
|
64
|
+
version: "0"
|
65
|
+
version:
|
66
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
67
|
+
requirements:
|
68
|
+
- - ">="
|
69
|
+
- !ruby/object:Gem::Version
|
70
|
+
version: "0"
|
71
|
+
version:
|
72
|
+
requirements: []
|
73
|
+
|
74
|
+
rubyforge_project:
|
75
|
+
rubygems_version: 1.3.5
|
76
|
+
signing_key:
|
77
|
+
specification_version: 2
|
78
|
+
summary: Rack Middleware to impose a rate limit on a web service (aka API Throttling)
|
79
|
+
test_files:
|
80
|
+
- test/test_api_throttling.rb
|
81
|
+
- test/test_api_throttling_hash.rb
|
82
|
+
- test/test_api_throttling_memcache.rb
|
83
|
+
- test/test_handlers.rb
|
84
|
+
- test/test_helper.rb
|