Implementing Rate Limiting in Rails - Part 2

Sun, Oct 13, 2013

The first part of this series can be found here.

The first part of this series looked at how to implement basic rate limiting in a Rails application. However, as pointed out in the improvements section, the implementation was not complete - it did not provide clients enough information about the rate limiting that is in place and how long they should wait before making further requests once they hit the limit.

In order to tell the client about the rate limit parameters, the mechanism needs to be able to set headers on the response. While a before_filter is useful to limit the requests, it can not change the response from a valid request. One could use an after_filter to achieve this, but a Rack middleware 1 is a more suitable solution given that middlewares can act up on a request as well as the response generated by the application for that request.

We will need to comment out the before_filter that was introduced in Part 1. Then we will define a blank middleware and wire it up. The convention is to define middlwares in app/middleware.

# app/middleware/rate_limit.rb

class RateLimit
  def initialize(app)
    @app = app
  end

  def call(env)
    @app.env
  end
end

This middleware is wired up as follows:

# config/application.rb

class Application < Rails::Application
  ...
  config.middleware.use "RateLimit"
end

Basic Rate Limiting

Let’s re-implement what we implemented in Part 1 using the middleware.

  def call(env)
    client_ip = env["REMOTE_ADDR"]
    key = "count:#{client_ip}"
    count = REDIS.get(key)
    unless count
      REDIS.set(key, 0)
      REDIS.expire(key, THROTTLE_TIME_WINDOW)
    end

    if count.to_i >= THROTTLE_MAX_REQUESTS
      [
       429,
       {},
       [message]
      ]
    else
      REDIS.incr(key)
      @app.call(env)
    end
  end

  private
  def message
    {
      :message => "You have fired too many requests. Please wait for some time."
    }.to_json
  end

Rate limit status

There are various header conventions for providing a client it’s rate limit status. For this example, we will use the convention that GitHub 2 and Twitter 3 use. The following headers represent the rate limit status:

  • X-RateLimit-Limit - The maximum number of requests that the client is permitted to make in the time window.
  • X-RateLimit-Remaining - The number of requests remaining in the current rate limit window.
  • X-RateLimit-Reset - The time at which the current rate limit window resets in UTC epoch seconds 4.

The middleware will set these headers for all requests with the following change:

  def call(env)
    client_ip = env["REMOTE_ADDR"]
    key = "count:#{client_ip}"
    count = REDIS.get(key)
    unless count
      REDIS.set(key, 0)
      REDIS.expire(key, THROTTLE_TIME_WINDOW)
    end

    if count.to_i >= THROTTLE_MAX_REQUESTS
      [
       429,
       rate_limit_headers(count, key),
       [message]
      ]
    else
      REDIS.incr(key)
      status, headers, body = @app.call(env)
      [
       status,
       headers.merge(rate_limit_headers(count.to_i + 1, key)),
       body
      ]
    end
  end

  private
  def message
    {
      :message => "You have fired too many requests. Please wait for some time."
    }.to_json
  end

  def rate_limit_headers(count, key)
    ttl = REDIS.ttl(key)
    time = Time.now.to_i
    time_till_reset = (time + ttl.to_i).to_s
    {
      "X-Rate-Limit-Limit" =>  "60",
      "X-Rate-Limit-Remaining" => (60 - count.to_i).to_s,
      "X-Rate-Limit-Reset" => time_till_reset
    }
  end

This computes the time remaining till the limit is reset and the number of requests remaining and sets the appropriate headers.

Let’s test this.

bash$ for i in {1..100}
do
curl -i http://localhost:3000/foo.json >> /tmp/headers.log
done

bash$ less /tmp/headers.log | grep X-Rate-Limit
X-Rate-Limit-Limit: 60
X-Rate-Limit-Remaining: 59
X-Rate-Limit-Reset: 1381717125
X-Rate-Limit-Limit: 60
X-Rate-Limit-Remaining: 58
X-Rate-Limit-Reset: 1381717125
...
X-Rate-Limit-Limit: 60
X-Rate-Limit-Remaining: 1
X-Rate-Limit-Reset: 1381717124
X-Rate-Limit-Limit: 60
X-Rate-Limit-Remaining: 0
X-Rate-Limit-Reset: 1381717124
X-Rate-Limit-Limit: 60
X-Rate-Limit-Remaining: 0
X-Rate-Limit-Reset: 1381717124

The code for this implementation is on my GitHub profile.