A gem to rate limit requests/actions of any kind.
Define thresholds, register usage and finally act on exceptions once thresholds get exceeded.
Prop supports two limiting strategies:
- Basic strategy (default): Prop will use an interval to define a window of time using simple div arithmetic. This means that it's a worst-case throttle that will allow up to two times the specified requests within the specified interval.
- Leaky bucket strategy: Prop also supports the Leaky Bucket algorithm, which is similar to the basic strategy but also supports bursts up to a specified threshold.
To store values, prop needs a cache:
# config/initializers/prop.rb
Prop.cache = Rails.cache # needs read/write/increment methods
When using the interval strategy, prop sets a key expiry to its interval. Because the leaky bucket strategy does not set a ttl, it is best to use memcached or similar for all prop caching, not redis.
You can define an optional callback that is invoked when a rate limit is reached. In a Rails application you could use such a handler to add notification support:
Prop.before_throttle do |handle, key, threshold, interval|
ActiveSupport::Notifications.instrument('throttle.prop', handle: handle, key: key, threshold: threshold, interval: interval)
end
You can define an optional callback that is invoked when a rate limit is checked. The callback will be invoked regardless of the result of the evaluation.
Prop.after_evaluated do |handle, counter, options|
Rails.logger.info "Prop #{handle} has just been check. current value: #{counter}"
end
Example: Limit on accepted emails per hour from a given user, by defining a threshold and interval (in seconds):
Prop.configure(:mails_per_hour, threshold: 100, interval: 1.hour, description: "Mail rate limit exceeded")
# Block requests by setting threshold to 0
Prop.configure(:mails_per_hour, threshold: 0, interval: 1.hour, description: "All mail is blocked")
# Throws Prop::RateLimited if the threshold/interval has been reached
Prop.throttle!(:mails_per_hour)
# Prop can be used to guard a block of code
Prop.throttle!(:expensive_request) { calculator.something_very_hard }
# Returns true if the threshold/interval has been reached
Prop.throttled?(:mails_per_hour)
# Sets the throttle count to 0
Prop.reset(:mails_per_hour)
# Returns the value of this throttle, usually a count, but see below for more
Prop.count(:mails_per_hour)
Prop will raise a KeyError
if you attempt to operate on an undefined handle.
Example: scope the throttling to a specific sender rather than running a global "mails per hour" throttle:
Prop.throttle!(:mails_per_hour, mail.from)
Prop.throttled?(:mails_per_hour, mail.from)
Prop.reset(:mails_per_hour, mail.from)
Prop.query(:mails_per_hour, mail.from)
The throttle scope can also be an array of values:
Prop.throttle!(:mails_per_hour, [ account.id, mail.from ])
If the threshold for a given handle and key combination is exceeded, Prop throws a Prop::RateLimited
.
This exception contains a "handle" reference and a "description" if specified during the configuration.
The handle allows you to rescue Prop::RateLimited
and differentiate action depending on the handle.
For example, in Rails you can use this in e.g. ApplicationController
:
rescue_from Prop::RateLimited do |e|
if e.handle == :authorization_attempt
render status: :forbidden, message: I18n.t(e.description)
elsif ...
end
end
Prop ships with a built-in Rack middleware that you can use to do all the exception handling.
When a Prop::RateLimited
error is caught, it will build an HTTP
429 Too Many Requests
response and set the following headers:
Retry-After: 32
Content-Type: text/plain
Content-Length: 72
Where Retry-After
is the number of seconds the client has to wait before retrying this end point.
The body of this response is whatever description Prop has configured for the throttle that got violated,
or a default string if there's none configured.
If you wish to do manual error messaging in these cases, you can define an error handler in your Prop configuration.
Here's how the default error handler looks - you use anything that responds to .call
and
takes the environment and a RateLimited
instance as argument:
error_handler = Proc.new do |env, error|
body = error.description || "This action has been rate limited"
headers = { "Content-Type" => "text/plain", "Content-Length" => body.size, "Retry-After" => error.retry_after }
[ 429, headers, [ body ]]
end
ActionController::Dispatcher.middleware.insert_before(ActionController::ParamsParser, error_handler: error_handler)
An alternative to this, is to extend Prop::Middleware
and override the render_response(env, error)
method.
In case you need to perform e.g. a manual bulk operation:
Prop.disabled do
# No throttles will be tested here
end
You can chose to override the threshold for a given key:
Prop.throttle!(:mails_per_hour, mail.from, threshold: current_account.mail_throttle_threshold)
When throttle
is invoked without argument, the key is nil and as such a scope of its own, i.e. these are equivalent:
Prop.throttle!(:mails_per_hour)
Prop.throttle!(:mails_per_hour, nil)
The default (and smallest possible) increment is 1, you can set that to any integer value using
:increment
which is handy for building time based throttles:
Prop.configure(:execute_time, threshold: 10, interval: 1.minute)
Prop.throttle!(:execute_time, account.id, increment: (Benchmark.realtime { execute }).to_i)
Decrement can be used to for example throttle before an expensive action and then give quota back when some condition is met.
Prop.throttle!(:api_counts, request.remote_ip, decrement: 1)
You can add optional configuration to a prop and retrieve it using Prop.configurations[:foo]
:
Prop.configure(:api_query, threshold: 10, interval: 1.minute, category: :api)
Prop.configure(:api_insert, threshold: 50, interval: 1.minute, category: :api)
Prop.configure(:password_failure, threshold: 5, interval: 1.minute, category: :auth)
Prop.configurations[:api_query][:category]
You can use Prop::RateLimited#config
to distinguish between errors:
rescue Prop::RateLimited => e
case e.config[:category]
when :api
raise APIRateLimit
when :auth
raise AuthFailure
...
end
You can opt to be notified when the throttle is breached for the first time.
This can be used to send notifications on breaches but prevent spam on multiple throttle breaches.
Prop.configure(:mails_per_hour, threshold: 100, interval: 1.hour, first_throttled: true)
throttled = Prop.throttle(:mails_per_hour, user.id, increment: 60)
if throttled
if throttled == :first_throttled
ApplicationMailer.spammer_warning(user).deliver_now
end
Rails.logger.warn("Not sending emails")
else
send_emails
end
# return values of throttle are: false, :first_throttled, true
Prop.first_throttled(:mails_per_hour, 1, increment: 60) # -> false
Prop.first_throttled(:mails_per_hour, 1, increment: 60) # -> :first_throttled
Prop.first_throttled(:mails_per_hour, 1, increment: 60) # -> true
# can also be accesses on `Prop::RateLimited` exceptions as `.first_throttled`
You can add two additional configurations: :strategy
and :burst_rate
to use the
leaky bucket algorithm.
Prop will handle the details after configured, and you don't have to specify :strategy
again when using throttle
, throttle!
or any other methods.
The leaky bucket algorithm used is "leaky bucket as a meter".
Prop.configure(:api_request, strategy: :leaky_bucket, burst_rate: 20, threshold: 5, interval: 1.minute)
:threshold
value here would be the "leak rate" of leaky bucket algorithm.
Copyright 2015 Zendesk
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.