/prop

Puts a cork in their requests

Primary LanguageRubyApache License 2.0Apache-2.0

Prop Build status

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.

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

Setting an After Evaluated Callback

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

Defining thresholds

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.

Scoping a throttle

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 ])

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:

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

Overriding threshold

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)

Optional configuration

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

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.

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

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.