Prop Build Status

Prop is a simple gem for rate limiting requests of any kind. It allows you to configure hooks for registering certain actions, such that you can define thresholds, register usage and finally act on exceptions once thresholds get exceeded.

Prop uses 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.

To get going with Prop, you first define the read and write operations. These define how you write a registered request and how to read the number of requests for a given action. For example, do something like the below in a Rails initializer:

Prop.read do |key|
  Rails.cache.read(key)
end

Prop.write do |key, value|
  Rails.cache.write(key, value)
end

You can choose to rely on whatever you'd like to use for transient storage. Prop does not do any sort of clean up of its key space, so you would have to implement that manually should you be using anything but an LRU cache like memcached.

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:

Prop.before_throttle do |handle, key, threshold, interval|
  ActiveSupport::Notifications.instrument('throttle.prop', handle: handle, key: key, threshold: threshold, interval: interval)
end

Defining thresholds

Once the read and write operations are defined, you can optionally define thresholds. If, for example, you want to have a threshold on accepted emails per hour from a given user, you could define a threshold and interval (in seconds) for this like so:

Prop.configure(:mails_per_hour, :threshold => 100, :interval => 1.hour, :description => "Mail rate limit exceeded")

The :mails_per_hour in the above is called the "handle". You can now put the throttle to work with these values, by passing the handle to the respective methods in Prop:

# 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 RuntimeError if you attempt to operate on an undefined handle.

Scoping a throttle

In many cases you will want to tie a specific key to a defined throttle. For example, you can 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, e.g.:

Prop.throttle!(:mails_per_hour, [ .id, mail.from ])

Error handling

If the throttle! method gets called more than "threshold" times within "interval in seconds" for a given handle and key combination, Prop throws a Prop::RateLimited error which is a subclass of StandardError. 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

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 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.

Disabling Prop

In case you need to perform e.g. a manual bulk operation:

Prop.disabled do
  # No throttles will be tested here
end

Threshold settings

You can chose to override the threshold for a given key:

Prop.throttle!(:mails_per_hour, mail.from, :threshold => .mail_throttle_threshold)

When the threshold are 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.setup(:execute_time, :threshold => 10, :interval => 1.minute)
Prop.throttle!(:execute_time, .id, :increment => (Benchmark.realtime { execute }).to_i)

License

Copyright 2013 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.