jduff-api-throttling 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/Rakefile +1 -0
- data/VERSION.yml +2 -2
- data/lib/api_throttling.rb +25 -13
- data/test/test_api_throttling.rb +81 -25
- data/test/test_api_throttling_hash.rb +1 -1
- data/test/test_api_throttling_memcache.rb +1 -1
- data/test/test_helper.rb +14 -21
- metadata +13 -4
data/Rakefile
CHANGED
@@ -10,6 +10,7 @@ begin
|
|
10
10
|
gemspec.homepage = "http://github.com/jduff/api-throttling/tree"
|
11
11
|
gemspec.description = "TODO"
|
12
12
|
gemspec.authors = ["Luc Castera", "John Duff"]
|
13
|
+
gemspec.add_development_dependency('context')
|
13
14
|
end
|
14
15
|
rescue LoadError
|
15
16
|
puts "Jeweler not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
|
data/VERSION.yml
CHANGED
data/lib/api_throttling.rb
CHANGED
@@ -5,34 +5,46 @@ require File.expand_path(File.dirname(__FILE__) + '/handlers/handlers')
|
|
5
5
|
class ApiThrottling
|
6
6
|
def initialize(app, options={})
|
7
7
|
@app = app
|
8
|
-
@options = {:requests_per_hour => 60, :cache=>:redis}.merge(options)
|
8
|
+
@options = {:requests_per_hour => 60, :cache=>:redis, :auth=>true}.merge(options)
|
9
9
|
@handler = Handlers.cache_handler_for(@options[:cache])
|
10
10
|
raise "Sorry, we couldn't find a handler for the cache you specified: #{@options[:cache]}" unless @handler
|
11
11
|
end
|
12
12
|
|
13
13
|
def call(env, options={})
|
14
|
-
auth
|
15
|
-
|
14
|
+
if @options[:auth]
|
15
|
+
auth = Rack::Auth::Basic::Request.new(env)
|
16
|
+
return auth_required unless auth.provided?
|
16
17
|
return bad_request unless auth.basic?
|
17
|
-
begin
|
18
|
-
cache = @handler.new(@options[:cache])
|
19
|
-
key = "#{auth.username}_#{Time.now.strftime("%Y-%m-%d-%H")}"
|
20
|
-
cache.increment(key)
|
21
|
-
return over_rate_limit if cache.get(key).to_i > @options[:requests_per_hour]
|
22
|
-
rescue Errno::ECONNREFUSED
|
23
|
-
# If Redis-server is not running, instead of throwing an error, we simply do not throttle the API
|
24
|
-
# It's better if your service is up and running but not throttling API, then to have it throw errors for all users
|
25
|
-
# Make sure you monitor your redis-server so that it's never down. monit is a great tool for that.
|
26
|
-
end
|
27
18
|
end
|
19
|
+
|
20
|
+
begin
|
21
|
+
cache = @handler.new(@options[:cache])
|
22
|
+
key = generate_key(env, auth)
|
23
|
+
cache.increment(key)
|
24
|
+
return over_rate_limit if cache.get(key).to_i > @options[:requests_per_hour]
|
25
|
+
rescue Errno::ECONNREFUSED
|
26
|
+
# If Redis-server is not running, instead of throwing an error, we simply do not throttle the API
|
27
|
+
# It's better if your service is up and running but not throttling API, then to have it throw errors for all users
|
28
|
+
# Make sure you monitor your redis-server so that it's never down. monit is a great tool for that.
|
29
|
+
end
|
28
30
|
@app.call(env)
|
29
31
|
end
|
30
32
|
|
33
|
+
def generate_key(env, auth)
|
34
|
+
return @options[:key].call(env, auth) if @options[:key]
|
35
|
+
auth ? "#{auth.username}_#{Time.now.strftime("%Y-%m-%d-%H")}" : "#{Time.now.strftime("%Y-%m-%d-%H")}"
|
36
|
+
end
|
37
|
+
|
31
38
|
def bad_request
|
32
39
|
body_text = "Bad Request"
|
33
40
|
[ 400, { 'Content-Type' => 'text/plain', 'Content-Length' => body_text.size.to_s }, [body_text] ]
|
34
41
|
end
|
35
42
|
|
43
|
+
def auth_required
|
44
|
+
body_text = "Authorization Required"
|
45
|
+
[ 401, { 'Content-Type' => 'text/plain', 'Content-Length' => body_text.size.to_s }, [body_text] ]
|
46
|
+
end
|
47
|
+
|
36
48
|
def over_rate_limit
|
37
49
|
body_text = "Over Rate Limit"
|
38
50
|
retry_after_in_seconds = (60 - Time.now.min) * 60
|
data/test/test_api_throttling.rb
CHANGED
@@ -7,33 +7,89 @@ require 'redis'
|
|
7
7
|
|
8
8
|
class ApiThrottlingTest < Test::Unit::TestCase
|
9
9
|
include Rack::Test::Methods
|
10
|
-
include BasicTests
|
11
|
-
|
12
|
-
def app
|
13
|
-
app = Rack::Builder.new {
|
14
|
-
use ApiThrottling, :requests_per_hour => 3
|
15
|
-
run lambda {|env| [200, {'Content-Type' => 'text/plain', 'Content-Length' => '12'}, ["Hello World!"] ] }
|
16
|
-
}
|
17
|
-
end
|
18
10
|
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
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
|
32
92
|
end
|
33
|
-
end
|
34
|
-
|
35
|
-
def test_cache_handler_should_be_redis
|
36
|
-
assert_equal "Handlers::RedisHandler", app.to_app.instance_variable_get(:@handler).to_s
|
37
93
|
end
|
38
94
|
|
39
95
|
end
|
@@ -7,7 +7,7 @@ class TestApiThrottlingHash < Test::Unit::TestCase
|
|
7
7
|
|
8
8
|
def app
|
9
9
|
app = Rack::Builder.new {
|
10
|
-
use ApiThrottling, :requests_per_hour => 3, :cache => HASH
|
10
|
+
use ApiThrottling, :requests_per_hour => 3, :cache => HASH
|
11
11
|
run lambda {|env| [200, {'Content-Type' => 'text/plain', 'Content-Length' => '12'}, ["Hello World!"] ] }
|
12
12
|
}
|
13
13
|
end
|
@@ -8,7 +8,7 @@ class TestApiThrottlingMemcache < Test::Unit::TestCase
|
|
8
8
|
|
9
9
|
def app
|
10
10
|
app = Rack::Builder.new {
|
11
|
-
use ApiThrottling, :requests_per_hour => 3, :cache => CACHE
|
11
|
+
use ApiThrottling, :requests_per_hour => 3, :cache => CACHE
|
12
12
|
run lambda {|env| [200, {'Content-Type' => 'text/plain', 'Content-Length' => '12'}, ["Hello World!"] ] }
|
13
13
|
}
|
14
14
|
end
|
data/test/test_helper.rb
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
require 'rubygems'
|
2
2
|
require 'rack/test'
|
3
3
|
require 'test/unit'
|
4
|
+
require 'context'
|
4
5
|
require File.expand_path(File.dirname(__FILE__) + '/../lib/api_throttling')
|
5
6
|
|
6
7
|
# this way we can include the module for any of the handler tests
|
@@ -14,25 +15,17 @@ module BasicTests
|
|
14
15
|
|
15
16
|
def test_fourth_request_should_be_blocked
|
16
17
|
authorize "joe", "secret"
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
get '/'
|
22
|
-
assert_equal 200, last_response.status
|
23
|
-
get '/'
|
24
|
-
assert_equal 503, last_response.status
|
18
|
+
3.times do
|
19
|
+
get '/'
|
20
|
+
assert_equal 200, last_response.status
|
21
|
+
end
|
25
22
|
get '/'
|
26
23
|
assert_equal 503, last_response.status
|
27
24
|
end
|
28
25
|
|
29
26
|
def test_over_rate_limit_should_only_apply_to_user_that_went_over_the_limit
|
30
27
|
authorize "joe", "secret"
|
31
|
-
get '/'
|
32
|
-
get '/'
|
33
|
-
get '/'
|
34
|
-
get '/'
|
35
|
-
get '/'
|
28
|
+
5.times { get '/' }
|
36
29
|
assert_equal 503, last_response.status
|
37
30
|
authorize "luc", "secret"
|
38
31
|
get '/'
|
@@ -41,21 +34,21 @@ module BasicTests
|
|
41
34
|
|
42
35
|
def test_over_rate_limit_should_return_a_retry_after_header
|
43
36
|
authorize "joe", "secret"
|
44
|
-
get '/'
|
45
|
-
get '/'
|
46
|
-
get '/'
|
47
|
-
get '/'
|
37
|
+
4.times { get '/' }
|
48
38
|
assert_equal 503, last_response.status
|
49
39
|
assert_not_nil last_response.headers['Retry-After']
|
50
40
|
end
|
51
41
|
|
52
42
|
def test_retry_after_should_be_less_than_60_minutes
|
53
43
|
authorize "joe", "secret"
|
54
|
-
get '/'
|
55
|
-
get '/'
|
56
|
-
get '/'
|
57
|
-
get '/'
|
44
|
+
4.times { get '/' }
|
58
45
|
assert_equal 503, last_response.status
|
59
46
|
assert last_response.headers['Retry-After'].to_i <= (60 * 60)
|
60
47
|
end
|
48
|
+
|
49
|
+
def test_should_require_authorization
|
50
|
+
get '/'
|
51
|
+
assert_equal 401, last_response.status
|
52
|
+
end
|
53
|
+
|
61
54
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: jduff-api-throttling
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Luc Castera
|
@@ -10,10 +10,19 @@ autorequire:
|
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
12
|
|
13
|
-
date: 2009-07-
|
13
|
+
date: 2009-07-06 00:00:00 -07:00
|
14
14
|
default_executable:
|
15
|
-
dependencies:
|
16
|
-
|
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:
|
17
26
|
description: TODO
|
18
27
|
email: duff.john@gmail.com
|
19
28
|
executables: []
|