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 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/TODO.md ADDED
@@ -0,0 +1,6 @@
1
+ # TODO
2
+
3
+
4
+ _Nothing_
5
+
6
+ _Suggestions Welcomed_
data/VERSION.yml ADDED
@@ -0,0 +1,4 @@
1
+ ---
2
+ :major: 0
3
+ :minor: 2
4
+ :patch: 1
@@ -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,15 @@
1
+ module Handlers
2
+ class HashHandler < Handler
3
+ cache_class "Hash"
4
+
5
+ def increment(key)
6
+ @cache[key] = (get(key)||0).to_i+1
7
+ end
8
+
9
+ def get(key)
10
+ @cache[key]
11
+ end
12
+
13
+ Handlers.add_handler self
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ module Handlers
2
+ class MemCacheHandler < Handler
3
+ cache_class "MemCache"
4
+
5
+ def increment(key)
6
+ @cache.set(key, (get(key)||0).to_i+1)
7
+ end
8
+
9
+ def get(key)
10
+ @cache.get(key)
11
+ end
12
+
13
+ Handlers.add_handler self
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ module Handlers
2
+ class RedisHandler < Handler
3
+ cache_class "Redis"
4
+
5
+ def increment(key)
6
+ @cache.incr(key)
7
+ end
8
+
9
+ def get(key)
10
+ @cache[key]
11
+ end
12
+
13
+ Handlers.add_handler self
14
+ end
15
+ 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
@@ -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