Class: Rack::Ratelimit

Inherits:
Object
  • Object
show all
Defined in:
lib/rack/ratelimit.rb

Overview

Ratelimit

  • Run multiple rate limiters in a single app

  • Scope each rate limit to certain requests: API, files, GET vs POST, etc.

  • Apply each rate limit by request characteristics: IP, subdomain, OAuth2 token, etc.

  • Flexible time window to limit burst traffic vs hourly or daily traffic:

    100 requests per 10 sec, 500 req/minute, 10000 req/hour, etc.
    
  • Fast, low-overhead implementation using counters per time window:

    timeslice = window * ceiling(current time / window)
    store.incr(timeslice)
    

Defined Under Namespace

Classes: MemcachedCounter, RedisCounter

Instance Method Summary collapse

Constructor Details

#initialize(app, options, &classifier) ⇒ Ratelimit

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_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 <period> seconds then retry your request."

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'] }


60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# File 'lib/rack/ratelimit.rb', line 60

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 #{@period} seconds then retry your request.")

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

Instance Method Details

#apply_rate_limit?(env) ⇒ Boolean

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

Returns:

  • (Boolean)


105
106
107
# File 'lib/rack/ratelimit.rb', line 105

def apply_rate_limit?(env)
  @exceptions.none? { |e| e.call(env) } && @conditions.all? { |c| c.call(env) }
end

#call(env) ⇒ Object

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.


122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/rack/ratelimit.rb', line 122

def call(env)
  if apply_rate_limit?(env) && classification = classify(env)

    # Marks the end of the current rate-limiting window.
    timestamp = @period * (Time.now.to_f / @period).ceil
    time = Time.at(timestamp).utc.xmlschema

    # Increment the request counter.
    count = @counter.increment(classification, timestamp)
    remaining = @max - count

    json = %({"name":"#{@name}","period":#{@period},"limit":#{@max},"remaining":#{remaining < 0 ? 0 : remaining},"until":"#{time}"})

    # 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, time]
      end

      [ @status,
        { 'X-Ratelimit' => json, 'Retry-After' => @period.to_s },
        [@error_message] ]

    # Otherwise, pass through then add some informational headers.
    else
      @app.call(env).tap do |status, headers, body|
        headers['X-Ratelimit'] = [headers['X-Ratelimit'], json].compact.join("\n")
      end
    end
  else
    @app.call(env)
  end
end

#classify(env) ⇒ Object

Give subclasses an opportunity to specialize classification.



110
111
112
# File 'lib/rack/ratelimit.rb', line 110

def classify(env)
  @classifier.call env
end

#condition(predicate = nil, &block) ⇒ Object

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.



90
91
92
93
# File 'lib/rack/ratelimit.rb', line 90

def condition(predicate = nil, &block)
  @conditions << predicate if predicate
  @conditions << block if block_given?
end

#exception(predicate = nil, &block) ⇒ Object

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.



98
99
100
101
# File 'lib/rack/ratelimit.rb', line 98

def exception(predicate = nil, &block)
  @exceptions << predicate if predicate
  @exceptions << block if block_given?
end