class Rack::Ratelimit

Ratelimit

Public Class Methods

new(app, options, &classifier) click to toggle source

Takes a block that classifies requests for rate limiting. Given a Rack env, return a string such as IP address, API token, etc. If the block returns nil, the request won't be rate-limited. If a block is not given, all requests get the same limits.

Required configuration:

rate: an array of [max requests, period in seconds]: [500, 5.minutes]

and one of

cache: a Dalli::Client instance
redis: a Redis instance
counter: Your own custom counter. Must respond to
  `#increment(classification_string, end_of_time_window_epoch_timestamp)`
  and return the counter value after increment.

Optional configuration:

name: name of the rate limiter. Defaults to 'HTTP'. Used in messages.
status: HTTP response code. Defaults to 429.
conditions: array of procs that take a rack env, all of which must
  return true to rate-limit the request.
exceptions: array of procs that take a rack env, any of which may
  return true to exclude the request from rate limiting.
logger: responds to #info(message). If provided, the rate limiter
  logs the first request that hits the rate limit, but none of the
  subsequently blocked requests.
error_message: the message returned in the response body when the rate
  limit is exceeded. Defaults to "<name> rate limit exceeded. Please
  wait %d seconds then retry your request." The number of seconds
  until the end of the rate-limiting window is interpolated into the
  message string, but the %d placeholder is optional if you wish to
  omit it.

Example:

Rate-limit bursts of POST/PUT/DELETE by IP address, return 503:

use(Rack::Ratelimit, name: 'POST',
  exceptions: ->(env) { env['REQUEST_METHOD'] == 'GET' },
  rate:   [50, 10.seconds],
  status: 503,
  cache:  Dalli::Client.new,
  logger: Rails.logger) { |env| Rack::Request.new(env).ip }

Rate-limit API traffic by user (set by Rack::Auth::Basic):

use(Rack::Ratelimit, name: 'API',
  conditions: ->(env) { env['REMOTE_USER'] },
  rate:   [1000, 1.hour],
  redis:  Redis.new(ratelimit_redis_config),
  logger: Rails.logger) { |env| env['REMOTE_USER'] }
# File lib/rack/ratelimit.rb, line 63
def initialize(app, options, &classifier)
  @app, @classifier = app, classifier
  @classifier ||= lambda { |env| :request }

  @name = options.fetch(:name, 'HTTP')
  @max, @period = options.fetch(:rate)
  @status = options.fetch(:status, 429)

  @counter =
    if counter = options[:counter]
      raise ArgumentError, 'Counter must respond to #increment' unless counter.respond_to?(:increment)
      counter
    elsif cache = options[:cache]
      MemcachedCounter.new(cache, @name, @period)
    elsif redis = options[:redis]
      RedisCounter.new(redis, @name, @period)
    else
      raise ArgumentError, ':cache, :redis, or :counter is required'
    end

  @logger = options[:logger]
  @error_message = options.fetch(:error_message, "#{@name} rate limit exceeded. Please wait %d seconds then retry your request.")

  @conditions = Array(options[:conditions])
  @exceptions = Array(options[:exceptions])
end

Public Instance Methods

apply_rate_limit?(env) click to toggle source

Apply the rate limiter if none of the exceptions apply and all the conditions are met.

# File lib/rack/ratelimit.rb, line 108
def apply_rate_limit?(env)
  @exceptions.none? { |e| e.call(env) } && @conditions.all? { |c| c.call(env) }
end
call(env) click to toggle source

Handle a Rack request:

* Check whether the rate limit applies to the request.
* Classify the request by IP, API token, etc.
* Calculate the end of the current time window.
* Increment the counter for this classification and time window.
* If count exceeds limit, return a 429 response.
* If it's the first request that exceeds the limit, log it.
* If the count doesn't exceed the limit, pass through the request.
# File lib/rack/ratelimit.rb, line 125
def call(env)
  # Accept an optional start-of-request timestamp from the Rack env for
  # upstream timing and for testing.
  now = env.fetch('ratelimit.timestamp', Time.now).to_f

  if apply_rate_limit?(env) && classification = classify(env)
    # Increment the request counter.
    epoch = ratelimit_epoch(now)
    count = @counter.increment(classification, epoch)
    remaining = @max - count

    # If exceeded, return a 429 Rate Limit Exceeded response.
    if remaining < 0
      # Only log the first hit that exceeds the limit.
      if @logger && remaining == -1
        @logger.info '%s: %s exceeded %d request limit for %s' % [@name, classification, @max, format_epoch(epoch)]
      end

      retry_after = seconds_until_epoch(epoch)

      [ @status,
        { 'X-Ratelimit' => ratelimit_json(remaining, epoch),
          'Retry-After' => retry_after.to_s },
        [ @error_message % retry_after ] ]

    # Otherwise, pass through then add some informational headers.
    else
      @app.call(env).tap do |status, headers, body|
        amend_headers headers, 'X-Ratelimit', ratelimit_json(remaining, epoch)
      end
    end
  else
    @app.call(env)
  end
end
classify(env) click to toggle source

Give subclasses an opportunity to specialize classification.

# File lib/rack/ratelimit.rb, line 113
def classify(env)
  @classifier.call env
end
condition(predicate = nil, &block) click to toggle source

Add a condition that must be met before applying the rate limit. Pass a block or a proc argument that takes a Rack env and returns true if the request should be limited.

# File lib/rack/ratelimit.rb, line 93
def condition(predicate = nil, &block)
  @conditions << predicate if predicate
  @conditions << block if block_given?
end
exception(predicate = nil, &block) click to toggle source

Add an exception that excludes requests from the rate limit. Pass a block or a proc argument that takes a Rack env and returns true if the request should be excluded from rate limiting.

# File lib/rack/ratelimit.rb, line 101
def exception(predicate = nil, &block)
  @exceptions << predicate if predicate
  @exceptions << block if block_given?
end

Private Instance Methods

amend_headers(headers, name, value) click to toggle source
# File lib/rack/ratelimit.rb, line 182
def amend_headers(headers, name, value)
  headers[name] = [headers[name], value].compact.join("\n")
end
format_epoch(epoch) click to toggle source
# File lib/rack/ratelimit.rb, line 171
def format_epoch(epoch)
  Time.at(epoch).utc.xmlschema
end
ratelimit_epoch(timestamp) click to toggle source

Calculate the end of the current rate-limiting window.

# File lib/rack/ratelimit.rb, line 163
def ratelimit_epoch(timestamp)
  @period * (timestamp / @period).ceil
end
ratelimit_json(remaining, epoch) click to toggle source
# File lib/rack/ratelimit.rb, line 167
def ratelimit_json(remaining, epoch)
  %({"name":"#{@name}","period":#{@period},"limit":#{@max},"remaining":#{remaining < 0 ? 0 : remaining},"until":"#{format_epoch(epoch)}"})
end
seconds_until_epoch(epoch) click to toggle source

Clamp negative durations in case we're in a new rate-limiting window.

# File lib/rack/ratelimit.rb, line 176
def seconds_until_epoch(epoch)
  sec = (epoch - Time.now.to_f).ceil
  sec = 0 if sec < 0
  sec
end