Praroter
This is built on top of, and forked from the excellent gem named Prorate by WeTransfer: https://github.com/WeTransfer/prorate
It was forked because we had slightly different needs for our endpoints:
- We bill calls based on how long the request takes (Prorate is built to bill per requests)
- We only know how long the request took by the end of the request cycle so we have to bill after the work is done (Prorate bills in the beginning of the request)
- Because we bill by the end of the request, we allow consumers to "owe" us time, that they have to pay back by waiting longer.
Installation
Add this line to your application's Gemfile:
gem 'praroter'
And then execute:
bundle install
Or install it yourself as:
gem install praroter
Implementation
The simplest mode of operation is throttling an endpoint, this is done by:
-
- First check if the bucket is empty
-
- Then do work
-
- Drain the amount of work done from bucket
Naïve Rails implementation
Within your Rails controller:
def index
# 1. First check if the bucket is empty
# -----------------------------------------------------------
redis = Redis.new
rate_limiter = Praroter::FillyBucket::Creator.new(redis: redis)
bucket = rate_limiter.setup_bucket(
key: [request.ip, params.require(:email)].join,
fill_rate: 2, # per second
capacity: 20 # default, acts as a buffer
)
bucket.throttle! # This will throw Prarotor::Throttled if level is negative
request_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
# 2. Then do work
# -----------------------------------------------------------
sleep(2.242)
# 3. Drain the amount of work from bucket
# -----------------------------------------------------------
request_end = Process.clock_gettime(Process::CLOCK_MONOTONIC)
request_diff = ((request_end - request_start) * 1000).to_i
bucket.drain(request_diff)
render plain: "Home"
end
To capture that exception, add this to the controller:
rescue_from Praroter::Throttled do |e|
response.set_header('X-Ratelimit-Cost', e.bucket_state.drained)
response.set_header('X-Ratelimit-Level', e.bucket_state.level)
response.set_header('X-Ratelimit-Capacity', e.bucket_state.capacity)
response.set_header('X-Ratelimit-Retry-After', e.retry_in_seconds)
render nothing: true, status: 429
end
Prettier Rails implementation
Within your initializers:
require 'prarotor'
redis = Redis.new
Rails.configuration.rate_limiter = Praroter::FillyBucket::Creator.new(redis: redis)
Within your Rails controller:
def index
# 1. First check if the bucket is empty
# -----------------------------------------------------------
ratelimit_bucket.throttle!
# 3. Drain the amount of work from bucket
# -----------------------------------------------------------
ratelimit_bucket.drain_block do
# 2. Then do work
# ---------------------------------------------------------
sleep(2.242)
end
end
protected
def ratelimit_bucket
@ratelimit_bucket ||= Rails.configuration.rate_limiter.setup_bucket(
key: [request.ip, params.require(:email)].join,
fill_rate: 2, # per second
capacity: 20 # default, acts as a buffer
)
end
Perfect Rails implementation
Within your initializers:
require 'prarotor'
redis = Redis.new
Rails.configuration.rate_limiter = Praroter::FillyBucket::Creator.new(redis: redis)
Within your Rails controller:
around_action :api_ratelimit
def index
# 2. Then do work
# ---------------------------------------------------------
sleep(2.242)
end
rescue_from Praroter::Throttled do |e|
response.set_header('X-Ratelimit-Cost', e.bucket_state.drained)
response.set_header('X-Ratelimit-Level', e.bucket_state.level)
response.set_header('X-Ratelimit-Capacity', e.bucket_state.capacity)
response.set_header('X-Ratelimit-Retry-After', e.retry_in_seconds)
render nothing: true, status: 429
end
protected
def api_ratelimit
# 1. First check if the bucket is empty
# -----------------------------------------------------------
ratelimit_bucket.throttle!
# 3. Drain the amount of work from bucket
# -----------------------------------------------------------
bucket_state = ratelimit_bucket.drain_block do
yield
end
response.set_header('X-Ratelimit-Cost', bucket_state.drained)
response.set_header('X-Ratelimit-Level', bucket_state.level)
response.set_header('X-Ratelimit-Capacity', bucket_state.capacity)
end
def ratelimit_bucket
@ratelimit_bucket ||= Rails.configuration.rate_limiter.setup_bucket(
key: [request.ip, params.require(:email)].join,
fill_rate: 2, # per second
capacity: 20 # default, acts as a buffer
)
end
Why Lua?
Praroter is a fork of Prorate, here's what they are saying about the choice of Lua:
Prorate is implementing throttling using the "Leaky Bucket" algorithm and is extensively described here. The implementation is using a Lua script, because is the only language available which runs inside Redis. Thanks to the speed benefits of Lua the script runs fast enough to apply it on every throttle call.
Using a Lua script in Prorate helps us achieve the following guarantees:
- The script will run atomically. The script is evaluated as a single Redis command. This ensures that the commands in the Lua script will never be interleaved with another client: they will always execute together.
-
Any usages of time will use the Redis time. Throttling requires a consistent and monotonic time source. The only monotonic and consistent time source which is usable in the context of Prorate, is the
TIME
result of Redis itself. We are throttling requests from different machines, which will invariably have clock drift between them. This way using the Redis serverTIME
helps achieve consistency.
Development
After checking out the repo, run bin/setup
to install dependencies. Then, run rake spec
to run the tests. You can also run bin/console
for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To release a new version, update the version number in version.rb
, and then run bundle exec rake release
, which will create a git tag for the version, push git commits and tags, and push the .gem
file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/kaspergrubbe/praroter.
License
The gem is available as open source under the terms of the MIT License.