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.
- RailsCast #151 - Rack Middleware. [return]
- GitHub API V3 - Rate limiting. [return]
- Twitter - REST API Rate Limiting in v1.1. [return]
- Wikipedia - Unix time - Encoding time as a number. [return]