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.
Files changed (3) hide show
  1. checksums.yaml +5 -5
  2. data/lib/rack/ratelimit.rb +92 -26
  3. metadata +12 -69
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 73b500a4dca2655841490da8c02106a49f69f62b
4
- data.tar.gz: 265204cbba7c818c0190eee355d55a7147083d1a
2
+ SHA256:
3
+ metadata.gz: f495171388825bee8e7248a597406cad160d6d0fe2adfb03f9d33617ab136832
4
+ data.tar.gz: 4b6256bf56d9384a80265ac5b78a9f63809e29e08bc33918b87c22ae3717cc5d
5
5
  SHA512:
6
- metadata.gz: da84278754fd180b780687f5819f4847d2e8fcf49378029e023b95706324b3b06eedd5cedf1c3b453e26723ed8b409ea78799e190497076db3411be9f9f55081
7
- data.tar.gz: 2af1a03a36288b8cbec652185ff1207b3e9052b58d3c0737c7a07159f349eeadaacf42c79acfde8ad2ae8a46f011b61fd04619d21b629117ae1f059ec4e9d7b3
6
+ metadata.gz: c8eb47b3e77c43c16df7c984463366f78d0be4c5f46b425e15d027932d5d43be4771e16a8338325c631408229bc3011576daf888aaff0d021ba505a080e06a9f
7
+ data.tar.gz: 467d0a44fab4f3b6490a912ad1ed8b699b315d4158a2ce709ce2743bfa16ba9c614e151d941674c2b830e4ac406ff1305fc09ee6fb2612bf7176d21477852d55
@@ -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])
@@ -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
- 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
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
- count = @counter.increment(classification, timestamp)
112
- remaining = @max - count + 1
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 <= 0
137
+ if remaining < 0
118
138
  # 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]
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' => json, 'Retry-After' => @period.to_s },
125
- [@error_message] ]
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['X-Ratelimit'] = [headers['X-Ratelimit'], json].compact.join("\n")
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
- 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
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, timestamp)
145
- key = 'rack-ratelimit/%s/%s/%i' % [@name, classification, timestamp]
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.0.0
4
+ version: 1.2.0
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: []