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.
Files changed (3) hide show
  1. checksums.yaml +5 -5
  2. data/lib/rack/ratelimit.rb +91 -26
  3. metadata +12 -69
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: e01063bca7c6d18ab0206611063a339dc7ddfad6
4
- data.tar.gz: ffb944f15efc8239948306e4948f25c8d488e07c
2
+ SHA256:
3
+ metadata.gz: 932df83350e7eb0747d9e4e380179c0f835bbadefb4dbb62ed0e4b0837778a1d
4
+ data.tar.gz: 3d9b668665f8bb7867719ebaae2a7804bf83794651d6c1f2261ef8cc4842619e
5
5
  SHA512:
6
- metadata.gz: b64f78b469c570ade7cb6f03e301c70550adb7486b3e5a2e36de522db35d80ce769ddd7f18e18274c6a5a6da2a2a5224244268a2a6378e5e9f8e91228ab6e7b7
7
- data.tar.gz: 0b4929fb74cd3da4a727e1ef5c0e5fbf160704e06be4e4e639df7be98e28f1dd6dada78918bd72cd2ee85d17efceb57cb123223777fda4b83a59dbcb27f7c763
6
+ metadata.gz: a319c6549dc021bd7fe3c703e6d1d484cf3daf6a2df5508e6d32de251077b1a012c36118359b974fb75cf7d7e341dc60232333eb468698eae48aeb3150ccdfd0
7
+ data.tar.gz: 1612618c51f43cb09ef159822807a5e46813e44185bbf65218bf7f8301c7050ac020e1dba494a0a58d2b70408973762b9966517bc3a00fad7a819d5f9fcba211
@@ -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 memcache counters per time window:
12
+ # * Fast, low-overhead implementation using counters per time window:
14
13
  # timeslice = window * ceiling(current time / window)
15
- # memcache.incr(counter for timeslice)
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
- # cache: a Dalli::Client instance, or an object that quacks like it.
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 <period> seconds then retry your request."
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
- # cache: Dalli::Client.new,
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 = Counter.new(options.fetch(:cache), @name, @period)
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 #{@period} seconds then retry your request.")
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
- if apply_rate_limit?(env) && classification = classify(env)
110
-
111
- # Marks the end of the current rate-limiting window.
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
- count = @counter.increment(classification, timestamp)
117
- remaining = @max - count + 1
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 <= 0
137
+ if remaining < 0
123
138
  # Only log the first hit that exceeds the limit.
124
- if @logger && remaining == 0
125
- @logger.info '%s: %s exceeded %d request limit for %s' % [@name, classification, @max, time]
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' => json, 'Retry-After' => @period.to_s },
130
- [@error_message] ]
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['X-Ratelimit'] = [headers['X-Ratelimit'], json].compact.join("\n")
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
- class Counter
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, timestamp)
150
- key = 'rack-ratelimit/%s/%s/%i' % [@name, classification, timestamp]
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.0.1
4
+ version: 1.2.1
5
5
  platform: ruby
6
6
  authors:
7
- - Jeremy Kemper
8
- autorequire:
7
+ - Jeremy Daer
8
+ autorequire:
9
9
  bindir: bin
10
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
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: '1.8'
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
- rubyforge_project:
97
- rubygems_version: 2.2.0
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: []