rack-ratelimit 1.0.1 → 1.2.1
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 +91 -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: 932df83350e7eb0747d9e4e380179c0f835bbadefb4dbb62ed0e4b0837778a1d
|
4
|
+
data.tar.gz: 3d9b668665f8bb7867719ebaae2a7804bf83794651d6c1f2261ef8cc4842619e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a319c6549dc021bd7fe3c703e6d1d484cf3daf6a2df5508e6d32de251077b1a012c36118359b974fb75cf7d7e341dc60232333eb468698eae48aeb3150ccdfd0
|
7
|
+
data.tar.gz: 1612618c51f43cb09ef159822807a5e46813e44185bbf65218bf7f8301c7050ac020e1dba494a0a58d2b70408973762b9966517bc3a00fad7a819d5f9fcba211
|
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])
|
@@ -106,33 +123,34 @@ module Rack
|
|
106
123
|
# * If it's the first request that exceeds the limit, log it.
|
107
124
|
# * If the count doesn't exceed the limit, pass through the request.
|
108
125
|
def call(env)
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
timestamp = @period * (Time.now.to_f / @period).ceil
|
113
|
-
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
|
114
129
|
|
130
|
+
if apply_rate_limit?(env) && classification = classify(env)
|
115
131
|
# Increment the request counter.
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
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
|
120
135
|
|
121
136
|
# If exceeded, return a 429 Rate Limit Exceeded response.
|
122
|
-
if remaining
|
137
|
+
if remaining < 0
|
123
138
|
# Only log the first hit that exceeds the limit.
|
124
|
-
if @logger && remaining ==
|
125
|
-
@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)]
|
126
141
|
end
|
127
142
|
|
143
|
+
retry_after = seconds_until_epoch(epoch)
|
144
|
+
|
128
145
|
[ @status,
|
129
|
-
{ 'X-Ratelimit' =>
|
130
|
-
|
146
|
+
{ 'X-Ratelimit' => ratelimit_json(remaining, epoch),
|
147
|
+
'Retry-After' => retry_after.to_s },
|
148
|
+
[ @error_message % retry_after ] ]
|
131
149
|
|
132
150
|
# Otherwise, pass through then add some informational headers.
|
133
151
|
else
|
134
152
|
@app.call(env).tap do |status, headers, body|
|
135
|
-
headers
|
153
|
+
amend_headers headers, 'X-Ratelimit', ratelimit_json(remaining, epoch)
|
136
154
|
end
|
137
155
|
end
|
138
156
|
else
|
@@ -140,14 +158,39 @@ module Rack
|
|
140
158
|
end
|
141
159
|
end
|
142
160
|
|
143
|
-
|
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
|
144
187
|
def initialize(cache, name, period)
|
145
188
|
@cache, @name, @period = cache, name, period
|
146
189
|
end
|
147
190
|
|
148
191
|
# Increment the request counter and return the current count.
|
149
|
-
def increment(classification,
|
150
|
-
key = 'rack-ratelimit/%s/%s/%i' % [@name, classification,
|
192
|
+
def increment(classification, epoch)
|
193
|
+
key = 'rack-ratelimit/%s/%s/%i' % [@name, classification, epoch]
|
151
194
|
|
152
195
|
# Try to increment the counter if it's present.
|
153
196
|
if count = @cache.incr(key, 1)
|
@@ -161,6 +204,28 @@ module Rack
|
|
161
204
|
else
|
162
205
|
@cache.incr(key, 1).to_i
|
163
206
|
end
|
207
|
+
rescue Dalli::DalliError
|
208
|
+
0
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
class RedisCounter
|
213
|
+
def initialize(redis, name, period)
|
214
|
+
@redis, @name, @period = redis, name, period
|
215
|
+
end
|
216
|
+
|
217
|
+
# Increment the request counter and return the current count.
|
218
|
+
def increment(classification, epoch)
|
219
|
+
key = 'rack-ratelimit/%s/%s/%i' % [@name, classification, epoch]
|
220
|
+
|
221
|
+
# Returns [count, expire_ok] response for each multi command.
|
222
|
+
# Return the first, the count.
|
223
|
+
@redis.multi do |redis|
|
224
|
+
redis.incr key
|
225
|
+
redis.expire key, @period
|
226
|
+
end.first
|
227
|
+
rescue Redis::BaseError
|
228
|
+
0
|
164
229
|
end
|
165
230
|
end
|
166
231
|
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.1
|
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: []
|