Simple Throttle

Continuous Integration Ruby Style Guide Gem Version

This gem provides a very simple throttling mechanism backed by Redis for limiting access to a resource. The throttle can be thought of as a limit on the number of calls in a set time frame (i.e. 100 calls per hour).

Usage

# Initialize Redis client
SimpleThrottle.set_redis(Redis.new)

# ...or provide a block that returns a redis client
SimpleThrottle.set_redis{ connection_pool.redis }

# ...or provide a Redis for a throttle to use
SimpleThrottle.new("user#{user.it}", limit: 10, ttl: 60, redis: Redis.new)

# Add a global throttle (max of 10 requests in 60 seconds)
SimpleThrottle.add(:things, limit: 10, ttl: 60)

# Throttle a global resource
if SimpleThrottle[:things].allowed!
  do_somthing
else
  raise "Too many requests. Resource available in #{SimpleThrottle[:things].wait_time} seconds"
end

# Throttle resource useage per user (100 per hour)
throttle = SimpleThrottle.new("resource@#{current_user.id}", limit: 100, ttl: 3600)
if throttle.allowed!
  do_somthing
else
  raise "Too many requests. Resource available in #{throttle.wait_time} seconds"
end

Calling allowed! will return true if the throttle limit has not yet been reached. If it does return true, then it will also start tracking that the call was made (hence the exclamation point syntax). There is no way to release a throttled call (that's why it's called SimpleThrottle).

The throttle data is kept in redis as a list of timestamps and will be auto expired if it falls out of use. The thottles time windows are rolling time windows and more calls will be allowed as soon as possible. So, if you have a throttle of, 100 requests per hour, and the throttle kicks in, you will be able to make the next throttled call one hour after the first call being tracked, not one hour after the last call.

You can also increment the throttle yourself with the increment! method. This will increment the throttle by the given amount and return the current count. The count will be capped by the throttle limit since excess requests beyond the limit are not tracked in Redis for performance reasons.

count = throttle.increment!
if count <= throttle.limit
  do_something
else
  raise "Too many requests: #{count}"
end

Pause to recover option

Throttles can also specify a pause_to_recover option set when they are created. When this flag is set, once a throttle check fails, it will continue to fail until the rate at which it is called drops below the maximum rate allowed by the throttle. This is designed for use where you want to detect run away processes constantly hitting a service. Without this set, the process would be able to utilize the resource up to the set limit. With it set, the process would need to pause temporarily to succeed again.

For example, if you have a throttle that allows 10 calls per 60 seconds, then a process hitting every second will succeed 10 times per minute. A similar throttle with the pause_to_recover option set, would only succeed on the first 10 calls. After that, it will continue to fail until the rate at which it is called drops below the maximum rate of the throttle (i.e. once every 6 seconds).

throttle_1 = SimpleThrottle.new("t1", limit: 10, ttl: 60)
throttle_2 = SimpleThrottle.new("t2", limit: 10, ttl: 60, pause_to_recover: true)

loop do
  if throttle_1.allowed!
    # This will be called 10 times every minute.
    do_thing_1
  end

  if throttle_2.allowed!
    # This will only be called 10 times in total because the throttle is never
    # given a chance to recover.
    do_thing_2
  end
end

Redis requirement

The redis gem version 4.5 or greater is required. Versions 4.5, 5.x, and 6.x are all supported and tested.

Redis server 3.2 or greater is required. The Lua script that maintains the throttle reads the server clock and then writes to the throttle list, which is only allowed under effects replication.

Time is measured by the Redis server clock

All throttle timestamps come from the Redis server's clock rather than the clock of the process calling the throttle. This is deliberate: a throttle is usually shared by many processes, and if each one stamped entries with its own clock, then clock skew between them would corrupt the ordering of the list and cause entries to expire too early or too late. Using a single clock means every client agrees on the time window no matter how skewed their local clocks are.

There are two consequences worth knowing about:

  • Manipulating time in tests has no effect on throttles. Tools like Timecop, ActiveSupport::Testing::TimeHelpers, or stubbing Time.now only change the clock inside your Ruby process. The Redis server is a separate process and keeps reporting the real time, so freezing or traveling through time will not expire throttle entries or change what wait_time returns. To test throttle expiration, create the throttle with a very short ttl and actually sleep:

    throttle = SimpleThrottle.new("test", limit: 1, ttl: 0.1)
    expect(throttle.allowed!).to eq true
    expect(throttle.allowed!).to eq false
    sleep(0.15)
    expect(throttle.allowed!).to eq true
    

    Use reset! to clear a throttle between tests.

  • Reading the server clock does not cost an extra round trip. allowed! and increment! read the clock inside the Lua script they already run, and peek and wait_time pipeline the clock read together with the read of the timestamp list. Every operation is still a single round trip to Redis.

Installation

Add this line to your application's Gemfile:

gem 'simple_throttle'

And then execute:

$ bundle

Or install it yourself as:

$ gem install simple_throttle

Contributing

Fork the repository and open a pull request on GitHub.

Please use the standardrb syntax and lint your code with standardrb --fix before submitting.

License

The gem is available as open source under the terms of the MIT License.