Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

429 Too Many Requests #5

Closed
wants to merge 22 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 0 additions & 35 deletions .gemspec

This file was deleted.

3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
.DS_Store
.tmp
.yardoc
.bundle
pkg
tmp
Gemfile.lock
*.gem
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# A sample Gemfile
source "http://rubygems.org"
gemspec

40 changes: 16 additions & 24 deletions README.md
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ Features

* Throttles a Rack application by enforcing a minimum time interval between
subsequent HTTP requests from a particular client, as well as by defining
a maximum number of allowed HTTP requests per a given time period (hourly
or daily).
a maximum number of allowed HTTP requests per a given time period (per minute,
hourly, or daily).
* Compatible with any Rack application and any Rack-based framework.
* Stores rate-limiting counters in any key/value store implementation that
responds to `#[]`/`#[]=` (like Ruby's hashes) or to `#get`/`#set` (like
Expand All @@ -24,6 +24,7 @@ Features
[redis][] gems.
* Compatible with [Heroku][]'s [memcached add-on][Heroku memcache]
(currently available as a free beta service).
* Compatible with Ruby 1.8.7 & 1.9

Examples
--------
Expand Down Expand Up @@ -60,6 +61,10 @@ Examples

use Rack::Throttle::Interval, :min => 3.0

### Allowing a maximum of 60 requests per minute

use Rack::Throttle::Minute, :max => 60

### Allowing a maximum of 100 requests per hour

use Rack::Throttle::Hourly, :max => 100
Expand All @@ -72,6 +77,7 @@ Examples

use Rack::Throttle::Daily, :max => 1000 # requests
use Rack::Throttle::Hourly, :max => 100 # requests
use Rack::Throttle::Hourly, :max => 60 # requests
use Rack::Throttle::Interval, :min => 3.0 # seconds

### Storing the rate-limiting counters in a GDBM database
Expand Down Expand Up @@ -99,6 +105,10 @@ Throttling Strategies

* `Rack::Throttle::Interval`: Throttles the application by enforcing a
minimum interval (by default, 1 second) between subsequent HTTP requests.
* `Rack::Throttle::Minute`: Throttles the application by defining a
maximum number of allowed HTTP requests per minute (by default, 60
requests per minute, which works out to an average of 1 request per
second).
* `Rack::Throttle::Hourly`: Throttles the application by defining a
maximum number of allowed HTTP requests per hour (by default, 3,600
requests per 60 minutes, which works out to an average of 1 request per
Expand Down Expand Up @@ -130,32 +140,13 @@ override the `#client_identifier` method.
HTTP Response Codes and Headers
-------------------------------

### 403 Forbidden (Rate Limit Exceeded)
In the past, various HTTP status codes has been used, but in 2012, the IETF
standardized on a new response status "429 Too Many Requests" [RFC6585]

When a client exceeds their rate limit, `Rack::Throttle` by default returns
a "403 Forbidden" response with an associated "Rate Limit Exceeded" message
a "429 Too Many Requests" header with an associated "Rate Limit Exceeded" message
in the response body.

An HTTP 403 response means that the server understood the request, but is
refusing to respond to it and an accompanying message will explain why.
This indicates an error on the client's part in exceeding the rate limits
outlined in the acceptable use policy for the site, service, or API.

### 503 Service Unavailable (Rate Limit Exceeded)

However, there exists a widespread practice of instead returning a "503
Service Unavailable" response when a client exceeds the set rate limits.
This is technically dubious because it indicates an error on the server's
part, which is certainly not the case with rate limiting - it was the client
that committed the oops, not the server.

An HTTP 503 response would be correct in situations where the server was
genuinely overloaded and couldn't handle more requests, but for rate
limiting an HTTP 403 response is more appropriate. Nonetheless, if you think
otherwise, `Rack::Throttle` does allow you to override the returned HTTP
status code by passing in a `:code => 503` option when constructing a
`Rack::Throttle::Limiter` instance.

Documentation
-------------

Expand Down Expand Up @@ -211,3 +202,4 @@ information, see <http://unlicense.org/> or the accompanying UNLICENSE file.
[redis]: http://rubygems.org/gems/redis
[Heroku]: http://heroku.com/
[Heroku memcache]: http://docs.heroku.com/memcache
[RFC6585]: http://tools.ietf.org/html/rfc6585
1 change: 0 additions & 1 deletion VERSION

This file was deleted.

4 changes: 2 additions & 2 deletions etc/gdbm.ru
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
$:.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
$:.unshift(File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib')))
require 'rack/throttle'
require 'gdbm'

use Rack::Throttle::Interval, :min => 3.0, :cache => GDBM.new('/tmp/throttle.db')

run lambda { |env| [200, {'Content-Type' => 'text/plain'}, "Hello, world!\n"] }
run lambda { |env| [200, {'Content-Type' => 'text/plain'}, ["Hello, world!\n"]] }
4 changes: 2 additions & 2 deletions etc/hash.ru
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
$:.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
$:.unshift(File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib')))
require 'rack/throttle'

use Rack::Throttle::Interval, :min => 3.0, :cache => {}

run lambda { |env| [200, {'Content-Type' => 'text/plain'}, "Hello, world!\n"] }
run lambda { |env| [200, {'Content-Type' => 'text/plain'}, ["Hello, world!\n"]] }
4 changes: 2 additions & 2 deletions etc/memcache-client.ru
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
$:.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
$:.unshift(File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib')))
require 'rack/throttle'
gem 'memcache-client'
require 'memcache'

use Rack::Throttle::Interval, :min => 3.0, :cache => MemCache.new('localhost:11211')

run lambda { |env| [200, {'Content-Type' => 'text/plain'}, "Hello, world!\n"] }
run lambda { |env| [200, {'Content-Type' => 'text/plain'}, ["Hello, world!\n"]] }
4 changes: 2 additions & 2 deletions etc/memcache.ru
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
$:.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
$:.unshift(File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib')))
require 'rack/throttle'
gem 'memcache'
require 'memcache'

use Rack::Throttle::Interval, :min => 3.0, :cache => Memcache.new(:server => 'localhost:11211')

run lambda { |env| [200, {'Content-Type' => 'text/plain'}, "Hello, world!\n"] }
run lambda { |env| [200, {'Content-Type' => 'text/plain'}, ["Hello, world!\n"]] }
4 changes: 2 additions & 2 deletions etc/memcached.ru
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
$:.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
$:.unshift(File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib')))
require 'rack/throttle'
gem 'memcached'
require 'memcached'

use Rack::Throttle::Interval, :min => 3.0, :cache => Memcached.new

run lambda { |env| [200, {'Content-Type' => 'text/plain'}, "Hello, world!\n"] }
run lambda { |env| [200, {'Content-Type' => 'text/plain'}, ["Hello, world!\n"]] }
4 changes: 2 additions & 2 deletions etc/redis.ru
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
$:.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
$:.unshift(File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib')))
require 'rack/throttle'
gem 'redis'
require 'redis'

use Rack::Throttle::Interval, :min => 3.0, :cache => Redis.new

run lambda { |env| [200, {'Content-Type' => 'text/plain'}, "Hello, world!\n"] }
run lambda { |env| [200, {'Content-Type' => 'text/plain'}, ["Hello, world!\n"]] }
1 change: 1 addition & 0 deletions lib/rack/throttle.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ module Throttle
autoload :TimeWindow, 'rack/throttle/time_window'
autoload :Daily, 'rack/throttle/daily'
autoload :Hourly, 'rack/throttle/hourly'
autoload :Minute, 'rack/throttle/minute'
autoload :VERSION, 'rack/throttle/version'
end
end
8 changes: 4 additions & 4 deletions lib/rack/throttle/interval.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ def initialize(app, options = {})
# @param [Rack::Request] request
# @return [Boolean]
def allowed?(request)
t1 = request_start_time(request)
t0 = cache_get(key = cache_key(request)) rescue nil
allowed = !t0 || (dt = t1 - t0.to_f) >= minimum_interval
start_time = request_start_time(request)
last_call_time = cache_get(key = cache_key(request)) rescue nil
allowed = !last_call_time || (dt = start_time - last_call_time.to_f) >= minimum_interval
begin
cache_set(key, t1)
cache_set(key, start_time)
allowed
rescue => e
# If an error occurred while trying to update the timestamp stored
Expand Down
19 changes: 13 additions & 6 deletions lib/rack/throttle/limiter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,22 @@ module Rack; module Throttle
# end
#
class Limiter

attr_reader :app
attr_reader :options


CODE = 429 # http://tools.ietf.org/html/rfc6585
MESSAGE = "Rate Limit Exceeded"
CONTENT_TYPE = "text/plain"
CHARSET = "utf-8"

##
# @param [#call] app
# @param [Hash{Symbol => Object}] options
# @option options [String] :cache (Hash.new)
# @option options [String] :key (nil)
# @option options [String] :key_prefix (nil)
# @option options [Integer] :code (403)
# @option options [Integer] :code (429)
# @option options [String] :message ("Rate Limit Exceeded")
def initialize(app, options = {})
@app, @options = app, options
Expand All @@ -31,7 +37,7 @@ def initialize(app, options = {})
# @see http://rack.rubyforge.org/doc/SPEC.html
def call(env)
request = Rack::Request.new(env)
allowed?(request) ? app.call(env) : rate_limit_exceeded
allowed?(request) ? app.call(env) : rate_limit_exceeded(request)
end

##
Expand Down Expand Up @@ -174,9 +180,10 @@ def request_start_time(request)
# Outputs a `Rate Limit Exceeded` error.
#
# @return [Array(Integer, Hash, #each)]
def rate_limit_exceeded
def rate_limit_exceeded(request)
options[:rate_limit_exceeded_callback].call(request) if options[:rate_limit_exceeded_callback]
headers = respond_to?(:retry_after) ? {'Retry-After' => retry_after.to_f.ceil.to_s} : {}
http_error(options[:code] || 403, options[:message] || 'Rate Limit Exceeded', headers)
http_error(options[:code] || CODE, options[:message] || MESSAGE, headers)
end

##
Expand All @@ -188,7 +195,7 @@ def rate_limit_exceeded
# @return [Array(Integer, Hash, #each)]
def http_error(code, message = nil, headers = {})
[code, {'Content-Type' => 'text/plain; charset=utf-8'}.merge(headers),
http_status(code) + (message.nil? ? "\n" : " (#{message})\n")]
[http_status(code), (message.nil? ? "\n" : " (#{message})\n")]]
end

##
Expand Down
43 changes: 43 additions & 0 deletions lib/rack/throttle/minute.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
module Rack; module Throttle
##
# This rate limiter strategy throttles the application by defining a
# maximum number of allowed HTTP requests per minute (by default, 60
# requests per minute, which works out to an average of 1 request per
# second).
#
# Note that this strategy doesn't use a sliding time window, but rather
# tracks requests per distinct minute. This means that the throttling
# counter is reset every minute.
#
# @example Allowing up to 60 requests/minute
# use Rack::Throttle::Minute
#
# @example Allowing up to 100 requests per hour
# use Rack::Throttle::Minute, :max => 100
#
class Minute < TimeWindow
##
# @param [#call] app
# @param [Hash{Symbol => Object}] options
# @option options [Integer] :max (60)
def initialize(app, options = {})
super
end

##
def max_per_minute
@max_per_hour ||= options[:max_per_minute] || options[:max] || 60
end

alias_method :max_per_window, :max_per_minute

protected

##
# @param [Rack::Request] request
# @return [String]
def cache_key(request)
[super, Time.now.strftime('%Y-%m-%dT%H:%M')].join(':')
end
end
end; end
2 changes: 1 addition & 1 deletion lib/rack/throttle/version.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
module Rack; module Throttle
module VERSION
MAJOR = 0
MINOR = 3
MINOR = 4
TINY = 0
EXTRA = nil

Expand Down
1 change: 0 additions & 1 deletion rack-throttle.gemspec

This file was deleted.

40 changes: 40 additions & 0 deletions rack-throttle.gemspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env ruby -rubygems
# -*- encoding: utf-8 -*-

$: << File.dirname(__FILE__) + "/lib"

require 'rack/throttle/version'

Gem::Specification.new do |gem|
gem.version = Rack::Throttle::VERSION.to_s
gem.date = Date.today.to_s

gem.name = 'viximo-rack-throttle'
gem.homepage = 'http://github.com/Viximo/rack-throttle'
gem.license = 'Public Domain' if gem.respond_to?(:license=)
gem.summary = 'HTTP request rate limiter for Rack applications.'
gem.description = 'Rack middleware for rate-limiting incoming HTTP requests.'
gem.rubyforge_project = 'datagraph'

gem.authors = ['Arto Bendiken', 'Brendon Murphy']
gem.email = '[email protected]'

gem.platform = Gem::Platform::RUBY
gem.files = %w(AUTHORS README UNLICENSE) + Dir.glob('lib/**/*.rb')
gem.bindir = %q(bin)
gem.executables = %w()
gem.default_executable = gem.executables.first
gem.require_paths = %w(lib)
gem.extensions = %w()
gem.test_files = %w()
gem.has_rdoc = false

gem.required_ruby_version = '>= 1.8.2'
gem.requirements = []
gem.add_development_dependency 'rack-test', '0.5.3'
gem.add_development_dependency 'rspec', '1.3.0'
gem.add_development_dependency 'yard' , '>= 0.5.5'
gem.add_development_dependency 'timecop', '0.3.4'
gem.add_runtime_dependency 'rack', '>= 1.0.0'
gem.post_install_message = nil
end
Loading