rack-ratelimit 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/lib/rack-ratelimit.rb +1 -0
- data/lib/rack/ratelimit.rb +162 -0
- metadata +101 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 73b500a4dca2655841490da8c02106a49f69f62b
|
4
|
+
data.tar.gz: 265204cbba7c818c0190eee355d55a7147083d1a
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: da84278754fd180b780687f5819f4847d2e8fcf49378029e023b95706324b3b06eedd5cedf1c3b453e26723ed8b409ea78799e190497076db3411be9f9f55081
|
7
|
+
data.tar.gz: 2af1a03a36288b8cbec652185ff1207b3e9052b58d3c0737c7a07159f349eeadaacf42c79acfde8ad2ae8a46f011b61fd04619d21b629117ae1f059ec4e9d7b3
|
@@ -0,0 +1 @@
|
|
1
|
+
require 'rack/ratelimit'
|
@@ -0,0 +1,162 @@
|
|
1
|
+
require 'dalli'
|
2
|
+
require 'logger'
|
3
|
+
require 'time'
|
4
|
+
|
5
|
+
module Rack
|
6
|
+
# = Ratelimit
|
7
|
+
#
|
8
|
+
# * Run multiple rate limiters in a single app
|
9
|
+
# * Scope each rate limit to certain requests: API, files, GET vs POST, etc.
|
10
|
+
# * Apply each rate limit by request characteristics: IP, subdomain, OAuth2 token, etc.
|
11
|
+
# * Flexible time window to limit burst traffic vs hourly or daily traffic:
|
12
|
+
# 100 requests per 10 sec, 500 req/minute, 10000 req/hour, etc.
|
13
|
+
# * Fast, low-overhead implementation using memcache counters per time window:
|
14
|
+
# timeslice = window * ceiling(current time / window)
|
15
|
+
# memcache.incr(counter for timeslice)
|
16
|
+
class Ratelimit
|
17
|
+
# Takes a block that classifies requests for rate limiting. Given a
|
18
|
+
# Rack env, return a string such as IP address, API token, etc. If the
|
19
|
+
# block returns nil, the request won't be rate-limited. If a block is
|
20
|
+
# not given, all requests get the same limits.
|
21
|
+
#
|
22
|
+
# Required configuration:
|
23
|
+
# rate: an array of [max requests, period in seconds]: [500, 5.minutes]
|
24
|
+
# cache: a Dalli::Client instance, or an object that quacks like it.
|
25
|
+
#
|
26
|
+
# Optional configuration:
|
27
|
+
# name: name of the rate limiter. Defaults to 'HTTP'. Used in messages.
|
28
|
+
# status: HTTP response code. Defaults to 429.
|
29
|
+
# conditions: array of procs that take a rack env, all of which must
|
30
|
+
# return true to rate-limit the request.
|
31
|
+
# exceptions: array of procs that take a rack env, any of which may
|
32
|
+
# return true to exclude the request from rate limiting.
|
33
|
+
# logger: responds to #info(message). If provided, the rate limiter
|
34
|
+
# logs the first request that hits the rate limit, but none of the
|
35
|
+
# subsequently blocked requests.
|
36
|
+
# error_message: the message returned in the response body when the rate
|
37
|
+
# limit is exceeded. Defaults to "<name> rate limit exceeded. Please
|
38
|
+
# wait <period> seconds then retry your request."
|
39
|
+
#
|
40
|
+
# Example:
|
41
|
+
#
|
42
|
+
# Rate-limit bursts of POST/PUT/DELETE by IP address, return 503:
|
43
|
+
# use(Rack::Ratelimit, name: 'POST',
|
44
|
+
# exceptions: ->(env) { env['REQUEST_METHOD'] == 'GET' },
|
45
|
+
# rate: [50, 10.seconds],
|
46
|
+
# status: 503,
|
47
|
+
# cache: Dalli::Client.new,
|
48
|
+
# logger: Rails.logger) { |env| Rack::Request.new(env).ip }
|
49
|
+
#
|
50
|
+
# Rate-limit API traffic by user (set by Rack::Auth::Basic):
|
51
|
+
# use(Rack::Ratelimit, name: 'API',
|
52
|
+
# conditions: ->(env) { env['REMOTE_USER'] },
|
53
|
+
# rate: [1000, 1.hour],
|
54
|
+
# cache: Dalli::Client.new,
|
55
|
+
# logger: Rails.logger) { |env| env['REMOTE_USER'] }
|
56
|
+
def initialize(app, options, &classifier)
|
57
|
+
@app, @classifier = app, classifier
|
58
|
+
@classifier ||= lambda { |env| :request }
|
59
|
+
|
60
|
+
@name = options.fetch(:name, 'HTTP')
|
61
|
+
@max, @period = options.fetch(:rate)
|
62
|
+
@status = options.fetch(:status, 429)
|
63
|
+
|
64
|
+
@counter = Counter.new(options.fetch(:cache), @name, @period)
|
65
|
+
|
66
|
+
@logger = options[:logger]
|
67
|
+
@error_message = options.fetch(:error_message, "#{@name} rate limit exceeded. Please wait #{@period} seconds then retry your request.")
|
68
|
+
|
69
|
+
@conditions = Array(options[:conditions])
|
70
|
+
@exceptions = Array(options[:exceptions])
|
71
|
+
end
|
72
|
+
|
73
|
+
# Add a condition that must be met before applying the rate limit.
|
74
|
+
# Pass a block or a proc argument that takes a Rack env and returns
|
75
|
+
# true if the request should be limited.
|
76
|
+
def condition(predicate = nil, &block)
|
77
|
+
@conditions << predicate if predicate
|
78
|
+
@conditions << block if block_given?
|
79
|
+
end
|
80
|
+
|
81
|
+
# Add an exception that excludes requests from the rate limit.
|
82
|
+
# Pass a block or a proc argument that takes a Rack env and returns
|
83
|
+
# true if the request should be excluded from rate limiting.
|
84
|
+
def exception(predicate = nil, &block)
|
85
|
+
@exceptions << predicate if predicate
|
86
|
+
@exceptions << block if block_given?
|
87
|
+
end
|
88
|
+
|
89
|
+
# Apply the rate limiter if none of the exceptions apply and all the
|
90
|
+
# conditions are met.
|
91
|
+
def apply_rate_limit?(env)
|
92
|
+
@exceptions.none? { |e| e.call(env) } && @conditions.all? { |c| c.call(env) }
|
93
|
+
end
|
94
|
+
|
95
|
+
# Handle a Rack request:
|
96
|
+
# * Check whether the rate limit applies to the request.
|
97
|
+
# * Classify the request by IP, API token, etc.
|
98
|
+
# * Calculate the end of the current time window.
|
99
|
+
# * Increment the counter for this classification and time window.
|
100
|
+
# * If count exceeds limit, return a 429 response.
|
101
|
+
# * If it's the first request that exceeds the limit, log it.
|
102
|
+
# * If the count doesn't exceed the limit, pass through the request.
|
103
|
+
def call(env)
|
104
|
+
if apply_rate_limit?(env) && classification = @classifier.call(env)
|
105
|
+
|
106
|
+
# Marks the end of the current rate-limiting window.
|
107
|
+
timestamp = @period * (Time.now.to_f / @period).ceil
|
108
|
+
time = Time.at(timestamp).utc.xmlschema
|
109
|
+
|
110
|
+
# Increment the request counter.
|
111
|
+
count = @counter.increment(classification, timestamp)
|
112
|
+
remaining = @max - count + 1
|
113
|
+
|
114
|
+
json = %({"name":"#{@name}","period":#{@period},"limit":#{@max},"remaining":#{remaining},"until":"#{time}"})
|
115
|
+
|
116
|
+
# If exceeded, return a 429 Rate Limit Exceeded response.
|
117
|
+
if remaining <= 0
|
118
|
+
# Only log the first hit that exceeds the limit.
|
119
|
+
if @logger && remaining == 0
|
120
|
+
@logger.info '%s: %s exceeded %d request limit for %s' % [@name, classification, @max, time]
|
121
|
+
end
|
122
|
+
|
123
|
+
[ @status,
|
124
|
+
{ 'X-Ratelimit' => json, 'Retry-After' => @period.to_s },
|
125
|
+
[@error_message] ]
|
126
|
+
|
127
|
+
# Otherwise, pass through then add some informational headers.
|
128
|
+
else
|
129
|
+
@app.call(env).tap do |status, headers, body|
|
130
|
+
headers['X-Ratelimit'] = [headers['X-Ratelimit'], json].compact.join("\n")
|
131
|
+
end
|
132
|
+
end
|
133
|
+
else
|
134
|
+
@app.call(env)
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
class Counter
|
139
|
+
def initialize(cache, name, period)
|
140
|
+
@cache, @name, @period = cache, name, period
|
141
|
+
end
|
142
|
+
|
143
|
+
# Increment the request counter and return the current count.
|
144
|
+
def increment(classification, timestamp)
|
145
|
+
key = 'rack-ratelimit/%s/%s/%i' % [@name, classification, timestamp]
|
146
|
+
|
147
|
+
# Try to increment the counter if it's present.
|
148
|
+
if count = @cache.incr(key, 1)
|
149
|
+
count.to_i
|
150
|
+
|
151
|
+
# If not, add the counter and set expiry.
|
152
|
+
elsif @cache.add(key, 1, @period, :raw => true)
|
153
|
+
1
|
154
|
+
|
155
|
+
# If adding failed, someone else added it concurrently. Increment.
|
156
|
+
else
|
157
|
+
@cache.incr(key, 1).to_i
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
metadata
ADDED
@@ -0,0 +1,101 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: rack-ratelimit
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Jeremy Kemper
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2014-03-21 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rack
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: dalli
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rake
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: minitest
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: 5.3.0
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: 5.3.0
|
69
|
+
description:
|
70
|
+
email: jeremy@bitsweat.net
|
71
|
+
executables: []
|
72
|
+
extensions: []
|
73
|
+
extra_rdoc_files: []
|
74
|
+
files:
|
75
|
+
- "./lib/rack-ratelimit.rb"
|
76
|
+
- "./lib/rack/ratelimit.rb"
|
77
|
+
homepage:
|
78
|
+
licenses:
|
79
|
+
- MIT
|
80
|
+
metadata: {}
|
81
|
+
post_install_message:
|
82
|
+
rdoc_options: []
|
83
|
+
require_paths:
|
84
|
+
- lib
|
85
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '1.8'
|
90
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
91
|
+
requirements:
|
92
|
+
- - ">="
|
93
|
+
- !ruby/object:Gem::Version
|
94
|
+
version: '0'
|
95
|
+
requirements: []
|
96
|
+
rubyforge_project:
|
97
|
+
rubygems_version: 2.2.0
|
98
|
+
signing_key:
|
99
|
+
specification_version: 4
|
100
|
+
summary: Flexible rate limits for your Rack apps
|
101
|
+
test_files: []
|