sinatra-rate-limiter 0.3.2 → 0.4.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 (4) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -21
  3. data/lib/sinatra/rate-limiter.rb +76 -82
  4. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 8adddf799a15960cccfb10260b144cd2b44f648d
4
- data.tar.gz: 39755bb89f40a13226ca738c64f12c4f9c6b5493
3
+ metadata.gz: 75be17ee12b4828c4e6640da0d910329df557225
4
+ data.tar.gz: fc79ea5b2aae041c72d8e1b4af609a19efa8ce09
5
5
  SHA512:
6
- metadata.gz: fa29b11d746463878859cf05f2a4061a9ae09b9748a5b0cbf79e94fd60c285314a840c2bb680f2de5da1cae1180188ed863e48a5b42ac332bca7a0edb07dc33d
7
- data.tar.gz: df6410fd0b9c87ac9ffff38ec7670f0db34e58d44b9501447384cc19e93f7b6dff9226c4da6659feb9ad939ef514d80936ac37407f96818e0a03071ba7eb1f30
6
+ metadata.gz: b80f42b1423bd489e32c6eb3d01667da5b436f2cf5d32865e7b7ff6eb637765134ab7a0f181135e9ab74eae142f4aa5baf2f326d6baf4ffb952ce649683ad9ab
7
+ data.tar.gz: a53669b2bea80a35db6cbd6b37d8383322f709e8b761a862a164bf06b47bde7ae3de256cb25ea7b2c9daeb5edee2d48b29097973b1f45510608cda975921c722
data/README.md CHANGED
@@ -46,6 +46,8 @@ different requests using the same bucket.
46
46
 
47
47
  ## Usage
48
48
 
49
+ ### Defining rate limits
50
+
49
51
  Use `rate_limit` in the pipeline of any route (i.e. in the route itself, or
50
52
  in a `before` filter, or in a Padrino controller, etc. `rate_limit` takes
51
53
  zero to infinite parameters, with the syntax:
@@ -61,6 +63,30 @@ the globally defined default options can be provided.
61
63
 
62
64
  See the _Examples_ section below for usage examples.
63
65
 
66
+ ### Error handling
67
+
68
+ When a rate limit is exceeded, the exception `Sinatra::RateLimiter::Exceeded`
69
+ is thrown. By default, this sends an response code `429` with an informative
70
+ plain text error message. You can use Sinatra's error handling to customise
71
+ this. E.g.:
72
+
73
+ ```
74
+ error Sinatra::RateLimiter::Exceeded do
75
+ status 400
76
+ content_type :json
77
+
78
+ {error: { message: env['sinatra.error'].message } }.to_json
79
+ end
80
+ ```
81
+
82
+ As well as the default error message being available in
83
+ `env['sinatra.error'].message`, `the env['sinatra.error.rate_limiter']`
84
+ object contains three values for the exceeded limit:
85
+
86
+ * `.requests` Integer of the number of requests allowed
87
+ * `.seconds` Integer of the number of seconds the request limit applies to
88
+ * `.try_again` Integer the number of seconds until the limit resets
89
+
64
90
  ## Configuration
65
91
 
66
92
  All configuration is optional. If no default limits are specified here,
@@ -76,8 +102,6 @@ you must specify limits with each call of `rate_limit`
76
102
  set :rate_limiter_redis_expires, 24*60*60
77
103
 
78
104
  set :rate_limiter_default_options, {
79
- error_code: 429,
80
- error_template: nil,
81
105
  send_headers: true,
82
106
  header_prefix: 'Rate-Limit',
83
107
  identifier: Proc.new{ |request| request.ip }
@@ -111,28 +135,10 @@ limiter's longest 'seconds' parameter.
111
135
 
112
136
  Default options provided to each call of `rate_limit`
113
137
 
114
- ##### `error_code` (Integer)
115
-
116
- The HTTP error code to send to the client when a rate limit is reached.
117
- Defaults to `429` per [RFC 6585](http://tools.ietf.org/html/rfc6585) but
118
- you may have your own reasons for wanting to use a different code (like 400
119
- or 503).
120
-
121
- ##### `error_template` (String)
122
-
123
- Defines a template to render when a rate limit is reached (e.g. a nice error
124
- page or a machine friendly JSON response). Three local variables are
125
- provided (all Integers):
126
-
127
- * `requests`: The number of requests that triggered the rate limit.
128
- * `seconds`: The rate limit period.
129
- * `try_again`: In how many seconds the client should try again.
130
-
131
138
  ##### `send_headers` (Boolean)
132
139
 
133
140
  Whether or not to send `Rate-Limit-*` headers to the client with each
134
- request. A `Retry-After` header is currently always sent when a rate
135
- limit is reached regardless of this setting.
141
+ request.
136
142
 
137
143
  Three headers are sent per defined limit:
138
144
 
@@ -145,6 +151,9 @@ If a bucket name is defined, it will be included in the header in the format
145
151
  also be added to differentiate them, e.g `Rate-Limit-1-*`, `Rate-Limit-2-*`,
146
152
  `Rate-Limit-Bucketname-1-*`, etc.
147
153
 
154
+ Additionally, a `Retry-After` header is sent containing the number of
155
+ seconds remaining until the limit resets.
156
+
148
157
  ##### `header_prefix` (String)
149
158
 
150
159
  Prefix for HTTP headers sent to client. Default is `Rate-Limit` (per
@@ -5,6 +5,9 @@ module Sinatra
5
5
 
6
6
  module RateLimiter
7
7
 
8
+ class Exceeded < StandardError
9
+ end
10
+
8
11
  module Helpers
9
12
 
10
13
  def rate_limit(*args)
@@ -18,25 +21,27 @@ module Sinatra
18
21
  limiter.options = options
19
22
 
20
23
  if error_locals = limiter.limits_exceeded?
21
- limiter.rate_limit_headers.each{|h,v| response.headers[h] = v} if limiter.options.send_headers
22
-
23
- response.headers['Retry-After'] = error_locals[:try_again]
24
- halt limiter.options.error_code, error_response(limiter, error_locals)
24
+ if limiter.options.send_headers
25
+ limiter.headers.each{|h,v| response.headers[h] = v}
26
+ response.headers['Retry-After'] = error_locals[:try_again]
27
+ end
28
+
29
+ request.env['sinatra.error.rate_limiter'] = Struct.new(*error_locals.keys).new(*error_locals.values)
30
+ raise Sinatra::RateLimiter::Exceeded, "Rate limit exceeded:" +
31
+ " #{error_locals[:requests]} requests in #{error_locals[:seconds]} seconds." +
32
+ " Try again in #{error_locals[:try_again]} seconds."
25
33
  end
26
34
 
27
- redis.setex([namespace,limiter.user_identifier,bucket,Time.now.to_f.to_s].join('/'),
28
- settings.rate_limiter_redis_expires,
29
- request.env['REQUEST_URI'])
30
-
31
- limiter.rate_limit_headers.each{|h,v| response.headers[h] = v} if limiter.options.send_headers
35
+ limiter.log_request
36
+ limiter.headers.each{|h,v| response.headers[h] = v} if limiter.options.send_headers
32
37
  end
33
38
 
34
39
  private
35
40
 
36
41
  def parse_args(args)
37
- bucket = (args.first.class == String) ? args.shift : 'default'
38
- options = (args.last.class == Hash) ? args.pop : {}
39
- limits = (args.size < 1) ? settings.rate_limiter_default_limits : args
42
+ bucket = (args.first.class == String) ? args.shift : 'default'
43
+ options = (args.last.class == Hash) ? args.pop : {}
44
+ limits = (args.size < 1) ? settings.rate_limiter_default_limits : args
40
45
 
41
46
  if (limits.size < 1)
42
47
  raise ArgumentError, 'No explicit or default limits values provided.'
@@ -46,8 +51,19 @@ module Sinatra
46
51
  raise ArgumentError, 'Wrong number of Fixnum parameters supplied.'
47
52
  elsif !(bucket =~ /^[a-zA-Z0-9\-]*$/)
48
53
  raise ArgumentError, 'Limit name must be a String containing only a-z, A-Z, 0-9, and -.'
49
- elsif (omap = (options.keys.map{|o| settings.rate_limiter_default_options.keys.include?(o)})).include?(false)
50
- raise ArgumentError, "Invalid option '#{options.keys[omap.index(false)]}'."
54
+ end
55
+
56
+ options.to_a.each do |option, value|
57
+ case option
58
+ when :send_headers
59
+ raise ArgumentError, 'send_headers must be true or false' if !(value == (true or false))
60
+ when :header_prefix
61
+ raise ArgumentError, 'header_prefix must be a String' if value.class != String
62
+ when :identifier
63
+ raise ArgumentError, 'identifier must be a Proc or nil' if (!value.nil? and value.class != Proc)
64
+ else
65
+ raise ArgumentError, "Invalid option #{option}"
66
+ end
51
67
  end
52
68
 
53
69
  return [bucket,
@@ -55,31 +71,6 @@ module Sinatra
55
71
  limits.each_slice(2).map{|a| {requests: a[0], seconds: a[1]}}]
56
72
  end
57
73
 
58
- def redis
59
- settings.rate_limiter_redis_conn
60
- end
61
-
62
- def namespace
63
- settings.rate_limiter_redis_namespace
64
- end
65
-
66
- def get_min_time_prefix(limits)
67
- now = Time.now.to_f
68
- oldest = Time.now.to_f - limits.sort_by{|l| -l[:seconds]}.first[:seconds]
69
-
70
- return now.to_s[0..((now/oldest).to_s.split(/^1\.|[1-9]+/)[1].length)].to_i.to_s
71
- end
72
-
73
- def error_response(limiter, locals)
74
- if limiter.options.error_template
75
- render limiter.options.error_template, locals: locals
76
- else
77
- content_type 'text/plain'
78
- "Rate limit exceeded (#{locals[:requests]} requests in #{locals[:seconds]} seconds). Try again in #{locals[:try_again]} seconds."
79
- end
80
- end
81
-
82
-
83
74
  end
84
75
 
85
76
  def self.registered(app)
@@ -89,8 +80,6 @@ module Sinatra
89
80
  app.set :rate_limiter_environments, [:production]
90
81
  app.set :rate_limiter_default_limits, [10, 20] # 10 requests per 20 seconds
91
82
  app.set :rate_limiter_default_options, {
92
- error_code: 429,
93
- error_template: nil,
94
83
  send_headers: true,
95
84
  header_prefix: 'Rate-Limit',
96
85
  identifier: Proc.new{ |request| request.ip }
@@ -99,45 +88,38 @@ module Sinatra
99
88
  app.set :rate_limiter_redis_conn, Redis.new
100
89
  app.set :rate_limiter_redis_namespace, 'rate_limit'
101
90
  app.set :rate_limiter_redis_expires, 24*60*60 # This must be larger than longest limit time period
91
+
92
+ app.error Sinatra::RateLimiter::Exceeded do
93
+ status 429
94
+ content_type 'text/plain'
95
+ env['sinatra.error'].message
96
+ end
102
97
  end
103
98
 
104
99
  end
105
100
 
106
101
  class RateLimit
102
+ attr_accessor :settings, :request, :options
103
+
107
104
  def initialize(bucket, limits)
108
105
  @bucket = bucket
109
106
  @limits = limits
110
107
  @time_prefix = get_min_time_prefix(@limits)
111
108
  end
112
109
 
113
- include Sinatra::RateLimiter::Helpers
110
+ def options=(options)
111
+ options = settings.rate_limiter_default_options.merge(options)
112
+ @options = Struct.new(*options.keys).new(*options.values)
113
+ end
114
114
 
115
115
  def history(seconds=0)
116
116
  redis_history.select{|t| seconds.eql?(0) ? true : t > (Time.now.to_f - seconds)}
117
117
  end
118
118
 
119
- def redis_history
120
- if @history
121
- @history
122
- else
123
- @history = redis.
124
- keys("#{[namespace,user_identifier,@bucket].join('/')}/#{@time_prefix}*").
125
- map{|k| k.split('/')[3].to_f}
126
- end
127
- end
128
-
129
- def user_identifier
130
- if options.identifier.class == Proc
131
- return options.identifier.call(request)
132
- else
133
- return request.ip
134
- end
135
- end
136
-
137
- def rate_limit_headers
119
+ def headers
138
120
  headers = []
139
121
 
140
- header_prefix = options.header_prefix + (@bucket.eql?('default') ? '' : '-' + @bucket)
122
+ header_prefix = @options.header_prefix + (@bucket.eql?('default') ? '' : '-' + @bucket)
141
123
  limit_no = 0 if @limits.length > 1
142
124
  @limits.each do |limit|
143
125
  limit_no = limit_no + 1 if limit_no
@@ -149,7 +131,7 @@ module Sinatra
149
131
  return headers
150
132
  end
151
133
 
152
- def limit_remaining(limit)
134
+ def limit_remaining(limit)
153
135
  limit[:requests] - history(limit[:seconds]).length
154
136
  end
155
137
 
@@ -166,34 +148,46 @@ module Sinatra
166
148
  end
167
149
  end
168
150
 
169
- def redis
170
- settings.rate_limiter_redis_conn
151
+ def log_request
152
+ redis.setex(
153
+ [namespace, user_identifier, @bucket, Time.now.to_f.to_s].join('/'),
154
+ @settings.rate_limiter_redis_expires,
155
+ nil)
171
156
  end
172
157
 
173
- def namespace
174
- settings.rate_limiter_redis_namespace
175
- end
158
+ private
176
159
 
177
- def options=(options)
178
- options = settings.rate_limiter_default_options.merge(options)
179
- @options = Struct.new(*options.keys).new(*options.values)
180
- end
181
- def options
182
- @options
160
+ def redis_history
161
+ if @history
162
+ @history
163
+ else
164
+ @history = redis.
165
+ keys("#{[namespace,user_identifier,@bucket].join('/')}/#{@time_prefix}*").
166
+ map{|k| k.split('/')[3].to_f}
167
+ end
183
168
  end
184
169
 
185
- def settings=(settings)
186
- @settings = settings
170
+ def user_identifier
171
+ if @options.identifier.class == Proc
172
+ return @options.identifier.call(request)
173
+ else
174
+ return request.ip
175
+ end
187
176
  end
188
- def settings
189
- @settings
177
+
178
+ def redis
179
+ @settings.rate_limiter_redis_conn
190
180
  end
191
181
 
192
- def request=(request)
193
- @request = request
182
+ def namespace
183
+ @settings.rate_limiter_redis_namespace
194
184
  end
195
- def request
196
- @request
185
+
186
+ def get_min_time_prefix(limits)
187
+ now = Time.now.to_f
188
+ oldest = Time.now.to_f - limits.sort_by{|l| -l[:seconds]}.first[:seconds]
189
+
190
+ return now.to_s[0..((now/oldest).to_s.split(/^1\.|[1-9]+/)[1].length)].to_i.to_s
197
191
  end
198
192
  end
199
193
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sinatra-rate-limiter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Warren Guy
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2015-05-31 00:00:00.000000000 Z
11
+ date: 2015-06-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: sinatra