For a API project I am using rack-throttle gem, which is a Rack middleware that provides logic for rate-limiting incoming HTTP requests to Rack applications.
Now, the usage was pretty straight forward as it is explained in the gem page but the requirements I had were different. So I had to extend the gem and customize the code to support my requirements. rack-throttle gem check limitation per host, I wanted to it per authentication token. Each API call must have a authentication token passed to get API result back, and I wanted to set different limit for different authentication-token, or you can say different user.
I used memcache to keep the counts. And I wanted the limit per day, so I extended Rack::Throttle::Daily.
Here is my code snippet in /lib/api_defender.rb
At First, in the initialization I want to check the Database for all the authentication tokens and their limits and want to populate memcache with those values:
require 'rack/throttle' class ApiDefender < Rack::Throttle::Daily def initialize(app) options = { :code => 403, :message => "Rate Limit Exceeded" } @app, @options = app, options api_auth_limits = User.select('authentication_token, api_req_limit') api_auth_limits.each do |a_l| Rails.cache.write(a_l.authentication_token+'limit', a_l.api_req_limit, expires_in: TOKEN_LIMIT_EXPIRATION_MIN) end rescue nil end
After that, the allowed method check if the request link eligible for this limit check? In my case, if the request is coming from a browser I do the limit per host, and if it not browser and pass authentication token I check the authentication token limit for this request. I also keep increasing the limit in memcache ot keep track and once it reaches the final limit per day I return forbidden.
def allowed?(request) if request_is_browser?(request).blank? token_val = get_token_val(request) max_per_window = Rails.cache.read(token_val+'limit') count_increase = cache_incr(request, max_per_window) if max_per_window.blank? api_auth_limits = User.select('authentication_token, req_limit').where(:authentication_token => token_val) Rails.cache.write(api_auth_limits[0].authentication_token+'limit', api_auth_limits[0].req_limit, expires_in: TOKEN_LIMIT_EXPIRATION_MIN) max_per_window = api_auth_limits[0].req_limit max_per_window = 0 if max_per_window.blank? end need_defense?(request) ? count_increase <= max_per_window : true else if need_defense?(request) token_expire_seconds = TOKEN_COUNTER_EXPIRATION_MIN*60 token_expire_seconds_for_time = TOKEN_COUNTER_EXPIRATION_MIN*60 + 120 host_ip = request.ip.to_s host_ip_limit = Rails.cache.read(host_ip+'limit') host_ip_count = Rails.cache.read(host_ip) if host_ip_limit.blank? Rails.cache.write(host_ip+'limit', BROWSER_API_REQ_LIMIT, expires_in: TOKEN_LIMIT_EXP_MIN) host_ip_limit = BROWSER_API_REQ_LIMIT end if host_ip_count.blank? current_time = Time.now Rails.cache.write(host_ip+'time', current_time, expires_in: token_expire_seconds_for_time) Rails.cache.write(host_ip, 1, expires_in: token_expire_seconds) host_ip_count = 1 elsif host_ip_count <= host_ip_limit host_ip_count = host_ip_count + 1 current_time = Time.now last_update_time = Rails.cache.read(host_ip+'time') seconds_left = ((token_expire_seconds - (current_time - last_update_time))) Rails.cache.write(host_ip, host_ip_count, expires_in: seconds_left) end host_ip_count max_per_window) token_expire_seconds = TOKEN_COUNTER_EXPIRATION_MIN*60 token_expire_seconds_for_time = TOKEN_COUNTER_EXPIRATION_MIN*60 + 120 if token_count.blank? current_time = Time.now Rails.cache.write(token_val, 1, expires_in: token_expire_seconds) Rails.cache.write(token_val+'time', current_time, expires_in: token_expire_seconds_for_time) count = 0 else current_time = Time.now last_update_time = Rails.cache.read(token_val+'time') seconds_left = ((token_expire_seconds - (current_time - last_update_time))) count = token_count Rails.cache.write(token_val, count + 1, expires_in: seconds_left) end count + 1 rescue true end
And the need_defense check if this path should be validated for request.
def need_defense?(request) request.fullpath.include? "api/v1/" end
The call() method is the one which return forbidden if limit exceeds or pass handle to the API application if the validation of limit pass.
def call(env) request = Rack::Request.new(env) token = get_token_val(request) existing_time = Rails.cache.read(token+'time') token = request.ip.to_s if existing_time.blank? token = 'Test' if token.blank? allowed?(request) ? app.call(env) : rate_limit_exceeded(token) end
The rate_limit_exceeded method is customized to return results the way I want it to.
def rate_limit_exceeded(token) existing_time = Rails.cache.read(token+'time') time_left = ((Time.now - existing_time)/60).round(2) if !existing_time.blank? time_attr = "min" time_attr = "time" if time_left.blank? time_left = "later" if time_left.blank? http_error("{'error':[{'message':'Sorry, Rate Limit Exceeded, please try again after #{time_left} #{time_attr}','code':403}]}") rescue time_left = "later time" http_error("{'error':[{'message':'Sorry, Rate Limit Exceeded, please try again after #{time_left}','code':403}]}") end
Also, I had to add this custom class in the environment files:
config.middleware.insert_after Rack::Lock, ApiDefender