sinatra-rate-limiter 0.3.2 → 0.4.0

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