Using Redis to implement rate limiting in a Ruby application

31 Mar 2020

Redis is an in memory data store that has a variety of applications. One of which can be to keep track of the number of requests a user has made in a given timeframe. If they exceed a quota that has been specified we can then raise an error or respond accordingly. In this post I will outline a class that accepts a unique identifier (a string that will be a reference to a specific user or account).

The RateLimiter Class

Below is a class that implements rate limitation at 15 requests per second for a specific identifier that is passed in.

class RateLimiter
  MAX_REQUESTS = 15
  EXPIRES_IN = 10.freeze

  def initialize(max_requests: MAX_REQUESTS, identifier:)
    @max_requests = max_requests
    @identifier = identifier
  end

  def limit
    limit_reached? ? raise_error : increment_counter
  end

  private

  def limit_reached?
    number_of_requests >= @max_requests
  end

  def redis
    @redis ||= Redis.new
  end

  def number_of_requests
    (redis.get(key) || 1).to_i
  end

  def key
    @key ||= "#{@identifier}:request_count:#{timestamp}"
  end

  def timestamp
    Time.now.to_i
  end

  def raise_error
    raise Errors::RateLimit, "Platform limit reached, please wait 1 second"
  end

  def increment_counter
    redis.multi do
      redis.incr(key)
      redis.expire(key, EXPIRES_IN)
    end
  end
end

The above class will check to see if the number of requests made with this identifier meets the threshold for limiting (by fetching the key value pair from Redis) and if it does it will raise an error which can be rescued elsewhere in the application. If the threshold is not met it will increment the request counter for the current second in Redis and set it to expire in ten seconds.

Breaking down the RateLimiter class

Constants

MAX_REQUESTS = 15
EXPIRES_IN = 10.freeze

The MAX_REQUESTS constant dictates the maximum number that can be made by the unique identifier in a single second. The EXPIRES_IN denotes the time to live that the count for the given identifier and timestamp will live in Redis.

#limit

def limit
  limit_reached? ? raise_error : increment_counter
end

This is the only public method of the class and is used to invoke the rate limiter (RateLimiter.new.limit). It is essentially the controller of the class. It calls the #limit_reached? function (covered below) to determine whether a rate limit error should be called or whether the counter for the current second should be incremented in Redis.

#key

def key
  @key ||= "#{@identifier}:request_count:#{timestamp}"
end

The key function memoizes the @key instance variable so that it can be referenced in any of the calls to Redis.

#increment_counter

def increment_counter
  redis.multi do
    redis.incr(key)
    redis.expire(key, EXPIRES_IN)
  end
end

If the current identifier is not over the rate limit for the current second this function is called. The current second is retrieved from the timestamp function with a call to Time.now.to_i.

redis.incr will increase the value of the passed key by 1 if it already exists in redis, otherwise it will create that key and set it to 1.

redis.expire will set a timeout on a key. After the timeout has expired, the key will automatically be deleted (in the case for this class, 10 seconds). Realistically, as soon as the current timestamp is in the past the key that has been generated can be forgotten about, setting the expiry date to 10 seconds is somewhat of a safety precaution.

redis.multi wraps any redis calls within the supplied block in a transaction so they can be called atomically by Redis.

Conclusion

In this post we created a class that can act as a rate limiter for a specific key that is passed to it. It will raise an error if that key has already made 15 requests in the current second, otherwise it will increment the unique key for the current second for redis to keep track of. This can then be referenced when that key makes subsequent requests. For more information on implementing rate limiting see the Redis documentation for incr, this post on medium as well as this discussion on Reddit.

The more I use redis the more I love it. It acts like a database that can be referenced quickly and dynamically and allows you to keep track of user activity on the fly without needlessly writing it to a file.