rack-ratelimit 1.0.0 → 1.2.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 +5 -5
- data/lib/rack/ratelimit.rb +92 -26
- metadata +12 -69
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: f495171388825bee8e7248a597406cad160d6d0fe2adfb03f9d33617ab136832
|
4
|
+
data.tar.gz: 4b6256bf56d9384a80265ac5b78a9f63809e29e08bc33918b87c22ae3717cc5d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c8eb47b3e77c43c16df7c984463366f78d0be4c5f46b425e15d027932d5d43be4771e16a8338325c631408229bc3011576daf888aaff0d021ba505a080e06a9f
|
7
|
+
data.tar.gz: 467d0a44fab4f3b6490a912ad1ed8b699b315d4158a2ce709ce2743bfa16ba9c614e151d941674c2b830e4ac406ff1305fc09ee6fb2612bf7176d21477852d55
|
data/lib/rack/ratelimit.rb
CHANGED
@@ -1,4 +1,3 @@
|
|
1
|
-
require 'dalli'
|
2
1
|
require 'logger'
|
3
2
|
require 'time'
|
4
3
|
|
@@ -10,9 +9,9 @@ module Rack
|
|
10
9
|
# * Apply each rate limit by request characteristics: IP, subdomain, OAuth2 token, etc.
|
11
10
|
# * Flexible time window to limit burst traffic vs hourly or daily traffic:
|
12
11
|
# 100 requests per 10 sec, 500 req/minute, 10000 req/hour, etc.
|
13
|
-
# * Fast, low-overhead implementation using
|
12
|
+
# * Fast, low-overhead implementation using counters per time window:
|
14
13
|
# timeslice = window * ceiling(current time / window)
|
15
|
-
#
|
14
|
+
# store.incr(timeslice)
|
16
15
|
class Ratelimit
|
17
16
|
# Takes a block that classifies requests for rate limiting. Given a
|
18
17
|
# Rack env, return a string such as IP address, API token, etc. If the
|
@@ -21,7 +20,12 @@ module Rack
|
|
21
20
|
#
|
22
21
|
# Required configuration:
|
23
22
|
# rate: an array of [max requests, period in seconds]: [500, 5.minutes]
|
24
|
-
#
|
23
|
+
# and one of
|
24
|
+
# cache: a Dalli::Client instance
|
25
|
+
# redis: a Redis instance
|
26
|
+
# counter: Your own custom counter. Must respond to
|
27
|
+
# `#increment(classification_string, end_of_time_window_epoch_timestamp)`
|
28
|
+
# and return the counter value after increment.
|
25
29
|
#
|
26
30
|
# Optional configuration:
|
27
31
|
# name: name of the rate limiter. Defaults to 'HTTP'. Used in messages.
|
@@ -35,7 +39,10 @@ module Rack
|
|
35
39
|
# subsequently blocked requests.
|
36
40
|
# error_message: the message returned in the response body when the rate
|
37
41
|
# limit is exceeded. Defaults to "<name> rate limit exceeded. Please
|
38
|
-
# wait
|
42
|
+
# wait %d seconds then retry your request." The number of seconds
|
43
|
+
# until the end of the rate-limiting window is interpolated into the
|
44
|
+
# message string, but the %d placeholder is optional if you wish to
|
45
|
+
# omit it.
|
39
46
|
#
|
40
47
|
# Example:
|
41
48
|
#
|
@@ -51,7 +58,7 @@ module Rack
|
|
51
58
|
# use(Rack::Ratelimit, name: 'API',
|
52
59
|
# conditions: ->(env) { env['REMOTE_USER'] },
|
53
60
|
# rate: [1000, 1.hour],
|
54
|
-
#
|
61
|
+
# redis: Redis.new(ratelimit_redis_config),
|
55
62
|
# logger: Rails.logger) { |env| env['REMOTE_USER'] }
|
56
63
|
def initialize(app, options, &classifier)
|
57
64
|
@app, @classifier = app, classifier
|
@@ -61,10 +68,20 @@ module Rack
|
|
61
68
|
@max, @period = options.fetch(:rate)
|
62
69
|
@status = options.fetch(:status, 429)
|
63
70
|
|
64
|
-
@counter =
|
71
|
+
@counter =
|
72
|
+
if counter = options[:counter]
|
73
|
+
raise ArgumentError, 'Counter must respond to #increment' unless counter.respond_to?(:increment)
|
74
|
+
counter
|
75
|
+
elsif cache = options[:cache]
|
76
|
+
MemcachedCounter.new(cache, @name, @period)
|
77
|
+
elsif redis = options[:redis]
|
78
|
+
RedisCounter.new(redis, @name, @period)
|
79
|
+
else
|
80
|
+
raise ArgumentError, ':cache, :redis, or :counter is required'
|
81
|
+
end
|
65
82
|
|
66
83
|
@logger = options[:logger]
|
67
|
-
@error_message = options.fetch(:error_message, "#{@name} rate limit exceeded. Please wait
|
84
|
+
@error_message = options.fetch(:error_message, "#{@name} rate limit exceeded. Please wait %d seconds then retry your request.")
|
68
85
|
|
69
86
|
@conditions = Array(options[:conditions])
|
70
87
|
@exceptions = Array(options[:exceptions])
|
@@ -92,6 +109,11 @@ module Rack
|
|
92
109
|
@exceptions.none? { |e| e.call(env) } && @conditions.all? { |c| c.call(env) }
|
93
110
|
end
|
94
111
|
|
112
|
+
# Give subclasses an opportunity to specialize classification.
|
113
|
+
def classify(env)
|
114
|
+
@classifier.call env
|
115
|
+
end
|
116
|
+
|
95
117
|
# Handle a Rack request:
|
96
118
|
# * Check whether the rate limit applies to the request.
|
97
119
|
# * Classify the request by IP, API token, etc.
|
@@ -101,33 +123,34 @@ module Rack
|
|
101
123
|
# * If it's the first request that exceeds the limit, log it.
|
102
124
|
# * If the count doesn't exceed the limit, pass through the request.
|
103
125
|
def call(env)
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
timestamp = @period * (Time.now.to_f / @period).ceil
|
108
|
-
time = Time.at(timestamp).utc.xmlschema
|
126
|
+
# Accept an optional start-of-request timestamp from the Rack env for
|
127
|
+
# upstream timing and for testing.
|
128
|
+
now = env.fetch('ratelimit.timestamp', Time.now).to_f
|
109
129
|
|
130
|
+
if apply_rate_limit?(env) && classification = classify(env)
|
110
131
|
# Increment the request counter.
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
json = %({"name":"#{@name}","period":#{@period},"limit":#{@max},"remaining":#{remaining},"until":"#{time}"})
|
132
|
+
epoch = ratelimit_epoch(now)
|
133
|
+
count = @counter.increment(classification, epoch)
|
134
|
+
remaining = @max - count
|
115
135
|
|
116
136
|
# If exceeded, return a 429 Rate Limit Exceeded response.
|
117
|
-
if remaining
|
137
|
+
if remaining < 0
|
118
138
|
# Only log the first hit that exceeds the limit.
|
119
|
-
if @logger && remaining ==
|
120
|
-
@logger.info '%s: %s exceeded %d request limit for %s' % [@name, classification, @max,
|
139
|
+
if @logger && remaining == -1
|
140
|
+
@logger.info '%s: %s exceeded %d request limit for %s' % [@name, classification, @max, format_epoch(epoch)]
|
121
141
|
end
|
122
142
|
|
143
|
+
retry_after = seconds_until_epoch(epoch)
|
144
|
+
|
123
145
|
[ @status,
|
124
|
-
{ 'X-Ratelimit' =>
|
125
|
-
|
146
|
+
{ 'X-Ratelimit' => ratelimit_json(remaining, epoch),
|
147
|
+
'Retry-After' => retry_after.to_s },
|
148
|
+
[ @error_message % retry_after ] ]
|
126
149
|
|
127
150
|
# Otherwise, pass through then add some informational headers.
|
128
151
|
else
|
129
152
|
@app.call(env).tap do |status, headers, body|
|
130
|
-
headers
|
153
|
+
amend_headers headers, 'X-Ratelimit', ratelimit_json(remaining, epoch)
|
131
154
|
end
|
132
155
|
end
|
133
156
|
else
|
@@ -135,14 +158,39 @@ module Rack
|
|
135
158
|
end
|
136
159
|
end
|
137
160
|
|
138
|
-
|
161
|
+
private
|
162
|
+
# Calculate the end of the current rate-limiting window.
|
163
|
+
def ratelimit_epoch(timestamp)
|
164
|
+
@period * (timestamp / @period).ceil
|
165
|
+
end
|
166
|
+
|
167
|
+
def ratelimit_json(remaining, epoch)
|
168
|
+
%({"name":"#{@name}","period":#{@period},"limit":#{@max},"remaining":#{remaining < 0 ? 0 : remaining},"until":"#{format_epoch(epoch)}"})
|
169
|
+
end
|
170
|
+
|
171
|
+
def format_epoch(epoch)
|
172
|
+
Time.at(epoch).utc.xmlschema
|
173
|
+
end
|
174
|
+
|
175
|
+
# Clamp negative durations in case we're in a new rate-limiting window.
|
176
|
+
def seconds_until_epoch(epoch)
|
177
|
+
sec = (epoch - Time.now.to_f).ceil
|
178
|
+
sec = 0 if sec < 0
|
179
|
+
sec
|
180
|
+
end
|
181
|
+
|
182
|
+
def amend_headers(headers, name, value)
|
183
|
+
headers[name] = [headers[name], value].compact.join("\n")
|
184
|
+
end
|
185
|
+
|
186
|
+
class MemcachedCounter
|
139
187
|
def initialize(cache, name, period)
|
140
188
|
@cache, @name, @period = cache, name, period
|
141
189
|
end
|
142
190
|
|
143
191
|
# Increment the request counter and return the current count.
|
144
|
-
def increment(classification,
|
145
|
-
key = 'rack-ratelimit/%s/%s/%i' % [@name, classification,
|
192
|
+
def increment(classification, epoch)
|
193
|
+
key = 'rack-ratelimit/%s/%s/%i' % [@name, classification, epoch]
|
146
194
|
|
147
195
|
# Try to increment the counter if it's present.
|
148
196
|
if count = @cache.incr(key, 1)
|
@@ -158,5 +206,23 @@ module Rack
|
|
158
206
|
end
|
159
207
|
end
|
160
208
|
end
|
209
|
+
|
210
|
+
class RedisCounter
|
211
|
+
def initialize(redis, name, period)
|
212
|
+
@redis, @name, @period = redis, name, period
|
213
|
+
end
|
214
|
+
|
215
|
+
# Increment the request counter and return the current count.
|
216
|
+
def increment(classification, epoch)
|
217
|
+
key = 'rack-ratelimit/%s/%s/%i' % [@name, classification, epoch]
|
218
|
+
|
219
|
+
# Returns [count, expire_ok] response for each multi command.
|
220
|
+
# Return the first, the count.
|
221
|
+
@redis.multi do |redis|
|
222
|
+
redis.incr key
|
223
|
+
redis.expire key, @period
|
224
|
+
end.first
|
225
|
+
end
|
226
|
+
end
|
161
227
|
end
|
162
228
|
end
|
metadata
CHANGED
@@ -1,84 +1,28 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: rack-ratelimit
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
|
-
- Jeremy
|
8
|
-
autorequire:
|
7
|
+
- Jeremy Daer
|
8
|
+
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
12
|
-
dependencies:
|
13
|
-
|
14
|
-
|
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
|
11
|
+
date: 2021-01-22 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description:
|
14
|
+
email: jeremydaer@gmail.com
|
71
15
|
executables: []
|
72
16
|
extensions: []
|
73
17
|
extra_rdoc_files: []
|
74
18
|
files:
|
75
19
|
- "./lib/rack-ratelimit.rb"
|
76
20
|
- "./lib/rack/ratelimit.rb"
|
77
|
-
homepage:
|
21
|
+
homepage: https://github.com/jeremy/rack-ratelimit
|
78
22
|
licenses:
|
79
23
|
- MIT
|
80
24
|
metadata: {}
|
81
|
-
post_install_message:
|
25
|
+
post_install_message:
|
82
26
|
rdoc_options: []
|
83
27
|
require_paths:
|
84
28
|
- lib
|
@@ -86,16 +30,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
86
30
|
requirements:
|
87
31
|
- - ">="
|
88
32
|
- !ruby/object:Gem::Version
|
89
|
-
version: '
|
33
|
+
version: '2.0'
|
90
34
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
91
35
|
requirements:
|
92
36
|
- - ">="
|
93
37
|
- !ruby/object:Gem::Version
|
94
38
|
version: '0'
|
95
39
|
requirements: []
|
96
|
-
|
97
|
-
|
98
|
-
signing_key:
|
40
|
+
rubygems_version: 3.1.4
|
41
|
+
signing_key:
|
99
42
|
specification_version: 4
|
100
43
|
summary: Flexible rate limits for your Rack apps
|
101
44
|
test_files: []
|