# Prop [![Build Status](https://travis-ci.org/zendesk/prop.png)](https://travis-ci.org/zendesk/prop) 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](https://en.wikipedia.org/wiki/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: ```ruby # 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. ## Setting a Callback 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: ```ruby Prop.before_throttle do |handle, key, threshold, interval| ActiveSupport::Notifications.instrument('throttle.prop', handle: handle, key: key, threshold: threshold, interval: interval) end ``` ## Defining thresholds Example: Limit on accepted emails per hour from a given user, by defining a threshold and interval (in seconds): ```ruby 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") ``` ```ruby # Throws Prop::RateLimitExceededError 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. ## Scoping a throttle Example: scope the throttling to a specific sender rather than running a global "mails per hour" throttle: ```ruby 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: ```ruby Prop.throttle!(:mails_per_hour, [ account.id, mail.from ]) ``` ## Error handling 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`: ```ruby rescue_from Prop::RateLimited do |e| if e.handle == :authorization_attempt render status: :forbidden, message: I18n.t(e.description) elsif ... end end ``` ### Using the Middleware 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](http://tools.ietf.org/html/draft-nottingham-http-new-status-02#section-4) 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: ```ruby 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. ## Disabling Prop In case you need to perform e.g. a manual bulk operation: ```ruby Prop.disabled do # No throttles will be tested here end ``` ## Overriding threshold You can chose to override the threshold for a given key: ```ruby 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: ```ruby 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: ```ruby 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. ```ruby Prop.throttle!(:api_counts, request.remote_ip, decrement: 1) ``` ## Optional configuration You can add optional configuration to a prop and retrieve it using `Prop.configurations[:foo]`: ```ruby 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: ```ruby rescue Prop::RateLimited => e case e.config[:category] when :api raise APIRateLimit when :auth raise AuthFailure ... end ``` ## First throttled 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. ```Ruby 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` ``` ## Using Leaky Bucket Algorithm You can add two additional configurations: `:strategy` and `:burst_rate` to use the [leaky bucket algorithm](https://en.wikipedia.org/wiki/Leaky_bucket). 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". ```ruby 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. ## License 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 http://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.