├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTORS.md ├── Gemfile ├── Guardfile ├── README.md ├── Rakefile ├── VERSION ├── lib └── rack │ ├── redis_throttle.rb │ └── redis_throttle │ ├── connection.rb │ ├── daily.rb │ ├── interval.rb │ ├── limiter.rb │ ├── testing │ └── connection.rb │ ├── time_window.rb │ └── version.rb ├── redis_throttle.gemspec └── spec ├── fixtures └── fake_app.rb ├── rack ├── redis_throttle │ ├── daily_no_redis_spec.rb │ └── daily_spec.rb └── spec_helper.rb ├── spec_helper.rb └── support └── matchers └── body.rb /.gitignore: -------------------------------------------------------------------------------- 1 | pkg/* 2 | Gemfile.lock 3 | *.gem 4 | .bundle 5 | .yardoc 6 | *.swp 7 | *.swo 8 | *.DS_Store 9 | coverage/* 10 | doc/* 11 | .rvmrc 12 | .yardoc 13 | .ruby-version 14 | tmp/* 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | services: 3 | - redis-server 4 | rvm: 5 | - 1.9.3 6 | - jruby-19mode 7 | - rbx 8 | - 2.0.0 9 | - 2.1.0 10 | matrix: 11 | allow_failures: 12 | - rvm: rbx 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Release v0.0.1 - First release (01/01/2013) 4 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | Special thanks to the following people for submitting patches 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | # Specify your gem's dependencies in rack_proxify.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard 'rspec', cli: '--format Fuubar --color', all_after_pass: false do 2 | watch(%r{^spec/.+_spec\.rb}) 3 | watch(%r{^lib/(.+)\.rb}) { |m| "spec/#{m[1]}_spec.rb" } 4 | watch(%r{^spec/spec_helper.rb}) { |m| "spec/" } 5 | end 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redis Throttle Middleware 2 | [![Build Status](https://travis-ci.org/lelylan/redis-throttle.svg)](https://travis-ci.org/lelylan/redis-throttle) 3 | 4 | This is a fork of the [Rack Throttle](http://github.com/datagraph/rack-throttle) middleware 5 | that provides logic for rate-limiting incoming HTTP requests to Rack applications using 6 | Redis as storage system. You can use `Rack::RedisThrottle` with any Ruby web framework based 7 | on Rack, including Ruby on Rails 3.0 and Sinatra. This gem was designed to experiment rate 8 | limit with Rails 3.x and [Doorkeeper](https://github.com/applicake/doorkeeper/). 9 | 10 | #### Thanks to Open Source 11 | 12 | Redis Throttle Middleware come to life thanks to the work I've made in Lelylan, an open source microservices architecture for the Internet of Things. If this project helped you in any way, think about giving us a star on Github. 13 | 14 | 15 | 16 | 17 | 18 | ## Features 19 | 20 | * Works only with Redis. 21 | * Automatically deploy by setting `ENV['REDIS_RATE_LIMIT_URL']`. 22 | * When the Redis connection is not available redis throttle skips the rate limit check (it does not blow up). 23 | * Automatically adds `X-RateLimit-Limit` and `X-RateLimit-Remaining` headers. 24 | * Set MockRedis while running your tests 25 | 26 | 27 | ## Requirements 28 | 29 | Redis Throttle is tested against MRI 1.9.3, 2.0, and 2.1.x. 30 | 31 | 32 | ## Installation 33 | 34 | Update your gem file and run `bundle` 35 | 36 | ```ruby 37 | gem 'redis-throttle', git: 'git://github.com/lelylan/redis-throttle.git' 38 | ``` 39 | 40 | ## Rails Example 41 | 42 | ```ruby 43 | # At the top of config/application.rb 44 | require 'rack/redis_throttle' 45 | 46 | # Inside the class of config/application.rb 47 | class Application < Rails::Application 48 | # Limit the daily number of requests to 2500 49 | config.middleware.use Rack::RedisThrottle::Daily, max: 2500 50 | end 51 | ``` 52 | 53 | ## Sinatra example 54 | 55 | ```ruby 56 | #!/usr/bin/env ruby -rubygems 57 | require 'sinatra' 58 | require 'rack/throttle' 59 | use Rack::Throttle::Daily, max: 2500 60 | ``` 61 | 62 | ## Rack app example 63 | 64 | ```ruby 65 | #!/usr/bin/env rackup 66 | require 'rack/throttle' 67 | use Rack::Throttle::Daily max: 2500 68 | 69 | run lambda { |env| [200, {'Content-Type' => 'text/plain'}, "Hello, world!\n"] } 70 | ``` 71 | 72 | ## Customizations 73 | 74 | You can fully customize the implementation details of any of these strategies 75 | by simply subclassing one of the default implementations. 76 | 77 | In our example we want to reach these goals: 78 | 79 | * We want to use Doorkeper as authorization system (OAuth2) 80 | * The number of daily requests are based on the user id and not the IP 81 | address (default in `Rack::RedisThrottle`) 82 | * The number of daily requests is dynamically set per user by the 83 | `user#rate_limit` field. 84 | 85 | Now subclass `Rack::RedisThrottle::Daily`, create your own rules and use it in your Rails app 86 | 87 | ```ruby 88 | # /lib/middlewares/daily_rate_limit 89 | require 'rack/redis_throttle' 90 | 91 | class DailyRateLimit < Rack::RedisThrottle::Daily 92 | 93 | def call(env) 94 | @user_rate_limit = user_rate_limit(env) 95 | super 96 | end 97 | 98 | def client_identifier(request) 99 | @user_rate_limit.respond_to?(:_id) ? @user_rate_limit._id : 'user-unknown' 100 | end 101 | 102 | def max_per_window(request) 103 | @user_rate_limit.respond_to?(:rate_limit) ? @user_rate_limit.rate_limit : 1000 104 | end 105 | 106 | # Rate limit only requests sending the access token 107 | def need_protection?(request) 108 | request.env.has_key?('HTTP_AUTHORIZATION') 109 | end 110 | 111 | private 112 | 113 | def user_rate_limit(env) 114 | request = Rack::Request.new(env) 115 | token = request.env['HTTP_AUTHORIZATION'].split(' ')[-1] 116 | access_token = Doorkeeper::AccessToken.where(token: token).first 117 | access_token ? User.find(access_token.resource_owner_id) : nil 118 | end 119 | end 120 | ``` 121 | 122 | Now you can use it in your Rails App. 123 | 124 | ```ruby 125 | # config/application.rb 126 | module App 127 | class Application < Rails::Application 128 | 129 | # Puts your rate limit middleware as high as you can in your middleware stack 130 | config.middleware.insert_after Rack::Lock, 'DailyRateLimit' 131 | ``` 132 | 133 | ## Rate limit headers 134 | 135 | `Rack::RedisThrottle` automatically sets two rate limits headers to let the 136 | client know the max number of requests and the one availables. 137 | 138 | HTTP/1.1 200 OK 139 | X-RateLimit-Limit: 5000 140 | X-RateLimit-Remaining: 4999 141 | 142 | When you exceed the API calls limit your request is forbidden. 143 | 144 | HTTP/1.1 403 Forbidden 145 | X-RateLimit-Limit: 5000 146 | X-RateLimit-Remaining: 0 147 | 148 | 149 | # Testing your apps 150 | 151 | While testing your Rack app Mock the redis connection by requiring this file 152 | 153 | ```ruby 154 | # Rate limit fake redis connection 155 | require 'rack/redis_throttle/testing/connection' 156 | ``` 157 | 158 | 159 | 160 | ## HTTP client identification 161 | 162 | The rate-limiting counters stored and maintained by `Rack::RedisThrottle` are 163 | keyed to unique HTTP clients. By default, HTTP clients are uniquely identified 164 | by their IP address as returned by `Rack::Request#ip`. If you wish to instead 165 | use a more granular, application-specific identifier such as a session key or 166 | a user account name, you need only subclass a throttling strategy implementation 167 | and override the `#client_identifier` method. 168 | 169 | 170 | ## HTTP Response Codes and Headers 171 | 172 | When a client exceeds their rate limit, `Rack::RedisThrottle` by default returns 173 | a "403 Forbidden" response with an associated "Rate Limit Exceeded" message 174 | in the response body. If you need personalize it, for example with a 175 | JSON message. 176 | 177 | ```ruby 178 | def http_error(request, code, message = nil, headers = {}) 179 | [ code, { 'Content-Type' => 'application/json' }.merge(headers), [body(request).to_json] ] 180 | end 181 | 182 | def body(request) 183 | { 184 | status: 403, 185 | method: request.env['REQUEST_METHOD'], 186 | request: "#{request.env['rack.url_scheme']}://#{request.env['HTTP_HOST']}#{request.env['PATH_INFO']}", 187 | description: 'Rate limit exceeded', 188 | daily_rate_limit: max_per_window(request) 189 | } 190 | end 191 | ``` 192 | 193 | 194 | ## Notes 195 | 196 | ### Testing coverage 197 | 198 | Only `Rack::RedisThrottle::Daily` has a test suite. We will cover all 199 | the gem whenever I'll find more time and I'll see it being used widely. 200 | 201 | 202 | ## Contributing 203 | 204 | Fork the repo on github and send a pull requests with topic branches. Do not forget to 205 | provide specs to your contribution. 206 | 207 | 208 | ### Running specs 209 | 210 | * Fork and clone the repository. 211 | * Run `gem install bundler` to get the latest for the gemset. 212 | * Run `bundle install` for dependencies. 213 | * Run `bundle exec guard` and press enter to execute all specs. 214 | 215 | 216 | ## Spec guidelines 217 | 218 | Follow [betterspecs.org](http://betterspecs.org) guidelines. 219 | 220 | 221 | ## Coding guidelines 222 | 223 | Follow [github](https://github.com/styleguide/) guidelines. 224 | 225 | 226 | ## Feedback 227 | 228 | Use the [issue tracker](https://github.com/andreareginato/redis-throttle/issues) for bugs. 229 | [Mail](mailto:andrea.reginato@gmail.com) or [Tweet](http://twitter.com/andreareginato) 230 | us for any idea that can improve the project. 231 | 232 | 233 | ## Links 234 | 235 | * [GIT Repository](https://github.com/andreareginato/redis-throttle) 236 | * Initial inspiration from [Martinciu's dev blog](http://martinciu.com/2011/08/how-to-add-api-throttle-to-your-rails-app.html) 237 | 238 | 239 | ## Authors 240 | 241 | [Andrea Reginato](http://twitter.com/andreareginato) 242 | Thanks to [Lelylan](http://lelylan.com) for letting me share the code. 243 | 244 | 245 | ## Contributors 246 | 247 | Special thanks to the following people for submitting patches. 248 | 249 | 250 | ## Changelog 251 | 252 | See [CHANGELOG](redis-throttle/blob/master/CHANGELOG.md) 253 | 254 | 255 | ## Copyright 256 | 257 | Redis Throttle is free and unencumbered public domain software. 258 | See [LICENCE](redis-throttle/blob/master/LICENSE.md) 259 | 260 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | 3 | task default: :spec 4 | 5 | require 'rspec/core/rake_task' 6 | RSpec::Core::RakeTask.new do |t| 7 | t.rspec_opts = ['--color', '--format doc'] 8 | end 9 | 10 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.0.1 2 | -------------------------------------------------------------------------------- /lib/rack/redis_throttle.rb: -------------------------------------------------------------------------------- 1 | require 'rack' 2 | require 'rack/throttle' 3 | require 'redis' 4 | require 'hiredis' 5 | require 'redis-namespace' 6 | require 'active_support/core_ext/hash/reverse_merge' 7 | require 'active_support/core_ext/time/calculations' 8 | require 'active_support/core_ext/date/calculations' 9 | 10 | module Rack 11 | module RedisThrottle 12 | autoload :Connection, 'rack/redis_throttle/connection' 13 | autoload :Limiter, 'rack/redis_throttle/limiter' 14 | autoload :TimeWindow, 'rack/redis_throttle/time_window' 15 | autoload :Daily, 'rack/redis_throttle/daily' 16 | autoload :Interval, 'rack/redis_throttle/interval' 17 | autoload :VERSION, 'rack/redis_throttle/version' 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/rack/redis_throttle/connection.rb: -------------------------------------------------------------------------------- 1 | require 'rack' 2 | 3 | module Rack 4 | module RedisThrottle 5 | class Connection 6 | 7 | def self.create(options={}) 8 | url = redis_provider || 'redis://localhost:6379/0' 9 | options.reverse_merge!({ url: url }) 10 | client = Redis.connect(url: options[:url], driver: :hiredis) 11 | Redis::Namespace.new("redis-throttle:#{ENV['RACK_ENV']}:rate", redis: client) 12 | end 13 | 14 | private 15 | 16 | def self.redis_provider 17 | ENV['REDIS_RATE_LIMIT_URL'] 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/rack/redis_throttle/daily.rb: -------------------------------------------------------------------------------- 1 | require 'rack' 2 | 3 | module Rack 4 | module RedisThrottle 5 | class Daily < TimeWindow 6 | 7 | def max_per_day(request) 8 | @max_per_day ||= options[:max_per_day] || options[:max] || 86400 9 | end 10 | 11 | alias_method :max_per_window, :max_per_day 12 | 13 | protected 14 | 15 | def cache_key(request) 16 | [super, Time.now.utc.strftime('%Y-%m-%d')].join(':') 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/rack/redis_throttle/interval.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module RedisThrottle 3 | class Interval < Limiter 4 | 5 | # Returns `true` if sufficient time has passed since the last request. 6 | def allowed?(request) 7 | t1 = request_start_time(request) 8 | t0 = cache_get(key = cache_key(request)) rescue nil 9 | allowed = !t0 || (dt = t1 - t0.to_f) >= minimum_interval 10 | begin 11 | cache_set(key, t1) 12 | allowed 13 | rescue => e 14 | # If an error occurred while trying to update the timestamp stored 15 | # in the cache, we will fall back to allowing the request through. 16 | # This prevents the Rack application blowing up merely due to a 17 | # backend cache server (Memcached, Redis, etc.) being offline. 18 | allowed = true 19 | end 20 | end 21 | 22 | def retry_after 23 | minimum_interval 24 | end 25 | 26 | def minimum_interval 27 | @min ||= (@options[:min] || 1.0).to_f 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/rack/redis_throttle/limiter.rb: -------------------------------------------------------------------------------- 1 | require 'rack' 2 | 3 | module Rack 4 | module RedisThrottle 5 | class Limiter < Rack::Throttle::Limiter 6 | 7 | def initialize(app, options = {}) 8 | options.reverse_merge!({ cache: Rack::RedisThrottle::Connection.create }) 9 | @app, @options = app, options 10 | end 11 | 12 | def call(env) 13 | request = Rack::Request.new(env) 14 | if allowed?(request) 15 | status, headers, body = app.call(env) 16 | headers = rate_limit_headers(request, headers) if need_protection?(request) 17 | [status, headers, body] 18 | else 19 | rate_limit_exceeded(request) 20 | end 21 | end 22 | 23 | def cache 24 | begin 25 | case cache = (options[:cache] ||= {}) 26 | when Proc then cache.call 27 | else cache 28 | end 29 | rescue => e 30 | puts "ERROR: Redis connection not available. Rescuing cache.call" if ENV['DEBUG'] 31 | return {} 32 | end 33 | end 34 | 35 | def cache_has?(key) 36 | cache.get(key) rescue false 37 | end 38 | 39 | def cache_get(key, default = nil) 40 | begin 41 | cache.get(key) || default 42 | rescue Redis::BaseConnectionError => e 43 | puts "ERROR: Redis connection not available. Rescuing cache.get(key)" if ENV['DEBUG'] 44 | return 0 45 | end 46 | end 47 | 48 | def cache_set(key, value) 49 | cache.set(key, value) rescue 0 50 | end 51 | 52 | def cache_incr(request) 53 | begin 54 | key = cache_key(request) 55 | count = cache.incr(key) 56 | cache.expire(key, 1.day) if count == 1 57 | count 58 | rescue Redis::BaseConnectionError => e 59 | puts "ERROR: Redis connection not available. Rescuing cache.incr(key)" if ENV['DEBUG'] 60 | return 0 61 | end 62 | end 63 | 64 | def cache_key(request) 65 | id = client_identifier(request) 66 | case 67 | when options.has_key?(:key) 68 | options[:key].call(request) 69 | when options.has_key?(:key_prefix) 70 | [options[:key_prefix], id].join(':') 71 | else id 72 | end 73 | end 74 | 75 | # used to define the cache key 76 | def client_identifier(request) 77 | request.ip.to_s 78 | end 79 | 80 | def rate_limit_exceeded(request) 81 | headers = respond_to?(:retry_after) ? {'Retry-After' => retry_after.to_f.ceil.to_s} : {} 82 | http_error(request, options[:code] || 403, options[:message] || 'Rate Limit Exceeded', headers) 83 | end 84 | 85 | def http_error(request, code, message = nil, headers = {}) 86 | [code, {'Content-Type' => 'text/plain; charset=utf-8'}.merge(headers), 87 | [ http_status(code) + (message.nil? ? "\n" : " (#{message})")]] 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/rack/redis_throttle/testing/connection.rb: -------------------------------------------------------------------------------- 1 | require 'rack' 2 | require 'mock_redis' 3 | 4 | module Rack 5 | module RedisThrottle 6 | class Connection 7 | 8 | def self.create(options={}) 9 | MockRedis.new 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/rack/redis_throttle/time_window.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module RedisThrottle 3 | class TimeWindow < Limiter 4 | 5 | # Check my rate limit 6 | def allowed?(request) 7 | case 8 | when whitelisted?(request) then true 9 | when blacklisted?(request) then false 10 | else need_protection?(request) ? cache_incr(request) <= max_per_window(request) : true 11 | end 12 | end 13 | 14 | def need_protection?(request) 15 | true 16 | end 17 | 18 | def rate_limit_headers(request, headers) 19 | headers['X-RateLimit-Limit'] = max_per_window(request).to_s 20 | headers['X-RateLimit-Remaining'] = ([0, max_per_window(request) - (cache_get(cache_key(request)).to_i rescue 1)].max).to_s 21 | headers 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/rack/redis_throttle/version.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module RedisThrottle 3 | module VERSION 4 | MAJOR = 0 5 | MINOR = 1 6 | TINY = 0 7 | EXTRA = nil 8 | 9 | STRING = [MAJOR, MINOR, TINY].join('.') 10 | STRING << "-#{EXTRA}" if EXTRA 11 | 12 | ## 13 | # @return [String] 14 | def self.to_s() STRING end 15 | 16 | ## 17 | # @return [String] 18 | def self.to_str() STRING end 19 | 20 | ## 21 | # @return [Array(Integer, Integer, Integer)] 22 | def self.to_a() [MAJOR, MINOR, TINY] end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /redis_throttle.gemspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby -rubygems 2 | # -*- encoding: utf-8 -*- 3 | 4 | Gem::Specification.new do |gem| 5 | gem.version = File.read('VERSION').chomp 6 | gem.date = File.mtime('VERSION').strftime('%Y-%m-%d') 7 | gem.name = 'redis-throttle' 8 | gem.homepage = 'https://github.com/andreareginato' 9 | gem.summary = 'HTTP request rate limiter for Rack applications with Redigem.' 10 | gem.description = 'Rack middleware for rate-limiting incoming HTTP requests with Redigem.' 11 | 12 | gem.authors = ['Andrea Reginato'] 13 | gem.email = ['andrea.reginato@gmail.com'] 14 | 15 | gem.platform = Gem::Platform::RUBY 16 | gem.files = %w(CONTRIBUTORS.md README.md LICENSE.md VERSION) + Dir.glob('lib/**/*.rb') 17 | gem.bindir = %q(bin) 18 | gem.executables = %w() 19 | gem.default_executable = gem.executables.first 20 | gem.require_paths = %w(lib) 21 | gem.extensions = %w() 22 | gem.test_files = %w() 23 | gem.has_rdoc = false 24 | 25 | gem.rubyforge_project = 'redis-throttle' 26 | gem.post_install_message = nil 27 | 28 | gem.add_dependency 'rack' 29 | gem.add_dependency 'rack-throttle' 30 | gem.add_dependency 'redis' 31 | gem.add_dependency 'hiredis' 32 | gem.add_dependency 'redis-namespace' 33 | gem.add_dependency 'activesupport' 34 | 35 | gem.add_development_dependency 'rake' 36 | gem.add_development_dependency 'rspec' 37 | gem.add_development_dependency 'sinatra' 38 | gem.add_development_dependency 'foreman' 39 | gem.add_development_dependency 'timecop' 40 | gem.add_development_dependency 'mock_redis' 41 | gem.add_development_dependency 'hashie' 42 | gem.add_development_dependency 'rack-test' 43 | gem.add_development_dependency 'rb-fsevent' if RUBY_PLATFORM =~ /darwin/i 44 | gem.add_development_dependency 'guard' 45 | gem.add_development_dependency 'guard-rspec' 46 | gem.add_development_dependency 'fuubar' 47 | gem.add_development_dependency 'growl' 48 | end 49 | -------------------------------------------------------------------------------- /spec/fixtures/fake_app.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'sinatra' 3 | require 'rack/redis_throttle' 4 | 5 | module Rack 6 | module Test 7 | class FakeApp < Sinatra::Base 8 | 9 | get '/foo' do 10 | 'Hello Redis Throttler!' 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/rack/redis_throttle/daily_no_redis_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | 4 | describe Rack::RedisThrottle::Daily do 5 | 6 | describe 'when the redis connection is missing' do 7 | 8 | # middleware settings 9 | let(:cache) { Rack::RedisThrottle::Connection.create(url: 'redis://localhost:9999/0') } 10 | before { app.options[:max] = 5000 } 11 | before { app.options[:cache] = cache } 12 | 13 | 14 | describe 'when makes a request' do 15 | 16 | describe 'with the Authorization header' do 17 | 18 | describe 'when the rate limit is not reached' do 19 | 20 | before { get '/foo' } 21 | 22 | it 'returns a 200 status' do 23 | expect(last_response.status).to eq(200) 24 | end 25 | 26 | it 'returns the remaining requests header' do 27 | expect(last_response.headers['X-RateLimit-Remaining']).to eq('5000') 28 | end 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/rack/redis_throttle/daily_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Rack::RedisThrottle::Daily do 4 | 5 | # middleware settings 6 | before { app.options[:max] = 5000 } 7 | before { app.options[:cache] = MockRedis.new } 8 | 9 | 10 | let(:cache) { app.options[:cache] } 11 | 12 | let(:time_key) { Time.now.utc.strftime('%Y-%m-%d') } 13 | let(:client_key) { '127.0.0.1' } 14 | let(:cache_key) { "#{client_key}:#{time_key}" } 15 | 16 | let(:tomorrow_time_key) { Time.now.tomorrow.utc.strftime('%Y-%m-%d') } 17 | let(:tomorrow_cache_key) { "#{client_key}:#{tomorrow_time_key}" } 18 | 19 | before { cache.set cache_key, 1 } 20 | before { cache.set tomorrow_cache_key, 1 } 21 | 22 | describe 'when makes a request' do 23 | 24 | describe 'with the Authorization header' do 25 | 26 | describe 'when the rate limit is not reached' do 27 | 28 | before { get '/foo' } 29 | 30 | it 'returns a 200 status' do 31 | expect(last_response.status).to eq(200) 32 | end 33 | 34 | it 'returns the requests limit headers' do 35 | expect(last_response.headers['X-RateLimit-Limit']).not_to be_nil 36 | end 37 | 38 | it 'returns the remaining requests header' do 39 | expect(last_response.headers['X-RateLimit-Remaining']).not_to be_nil 40 | end 41 | 42 | it 'decreases the available requests' do 43 | previous = last_response.headers['X-RateLimit-Remaining'].to_i 44 | get '/', {}, 'AUTHORIZATION' => 'Bearer ' 45 | expect(previous).to eq(last_response.headers['X-RateLimit-Remaining'].to_i + 1) 46 | end 47 | end 48 | 49 | describe 'when reaches the rate limit' do 50 | 51 | before { cache.set cache_key, 5000 } 52 | before { get '/foo' } 53 | 54 | it 'returns a 403 status' do 55 | expect(last_response.status).to eq(403) 56 | end 57 | 58 | it 'returns a rate limited exceeded body' do 59 | expect(last_response.body).to eq('403 Forbidden (Rate Limit Exceeded)') 60 | end 61 | 62 | describe 'when comes the new day' do 63 | 64 | # If we are the 12-12-07 (any time) it gives the 12-12-08 00:00:00 UTC 65 | let!(:tomorrow) { Time.now.utc.tomorrow.beginning_of_day } 66 | before { Time.now.utc } 67 | before { Timecop.travel(tomorrow) } 68 | before { Time.now.utc } 69 | before { get '/foo', {}, 'AUTHORIZATION' => 'Bearer ' } 70 | after { Timecop.return } 71 | 72 | it 'returns a 200 status' do 73 | expect(last_response.status).to eq(200) 74 | end 75 | 76 | it 'returns a new rate limit' do 77 | end 78 | end 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/rack/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler/setup' 3 | require 'rack' 4 | require 'rack/test' 5 | require 'rspec' 6 | require 'rack/throttle' 7 | 8 | Dir[File.dirname(__FILE__) + '/support/**/*.rb'].each {|f| require f} 9 | 10 | require 'rack/redis_throttle' 11 | require File.dirname(__FILE__) + '/fixtures/fake_app' 12 | 13 | RSpec.configure do |config| 14 | config.mock_with :rspec 15 | config.include Rack::Test::Methods 16 | 17 | def app 18 | Rack::Lint.new(Rack::Test::FakeApp.new) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler/setup' 3 | require 'rack' 4 | require 'rack/test' 5 | require 'mock_redis' 6 | require 'rspec' 7 | require 'timecop' 8 | 9 | require File.dirname(__FILE__) + '/fixtures/fake_app' 10 | 11 | Dir[File.dirname(__FILE__) + '/support/**/*.rb'].each {|f| require f} 12 | 13 | RSpec.configure do |config| 14 | config.mock_with :rspec 15 | config.include Rack::Test::Methods 16 | end 17 | 18 | def app 19 | @target_app ||= Rack::Lint.new(Rack::Test::FakeApp.new) 20 | @daily_app ||= Rack::RedisThrottle::Daily.new(@target_app) 21 | end 22 | -------------------------------------------------------------------------------- /spec/support/matchers/body.rb: -------------------------------------------------------------------------------- 1 | RSpec::Matchers.define :have_body do |expected| 2 | match do |response| 3 | response.body.should == expected 4 | end 5 | 6 | description do 7 | "have body #{expected.inspect}" 8 | end 9 | end 10 | 11 | 12 | def mock_cache 13 | MockRedis.new 14 | end 15 | --------------------------------------------------------------------------------