rack-ratelimit 1.0.0 → 1.2.0

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 +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: []