rack-ratelimit 1.0.1 → 1.2.1

Sign up to get free protection for your applications and to get access to all the features.
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: []