sinatra-rate-limiter 0.2.2 → 0.3.1

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 +130 -33
  3. data/lib/sinatra/rate-limiter.rb +55 -41
  4. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 284920f0a11fd397c41ae1bd5e31f6e1f45bb846
4
- data.tar.gz: 58c8cc97bbab02f53ba781fbc4e1ef0ba0901eb5
3
+ metadata.gz: 006ef1d8689384395b156b7c636c4745287d07f0
4
+ data.tar.gz: 127f8f5d68dcd6898e8d21e6ac0fdfbaf16858b3
5
5
  SHA512:
6
- metadata.gz: 589661c3cd2635d4fad8c7ad421ef3ca02cc22c6dff9e7bcbb6dc495820d99c2b1063a530057907499a2725404c198cbf394d38a34362fd9dcc1e4afbe670554
7
- data.tar.gz: d279915ae972605f568070b3e0d63b2a35963b5a805dc82c3ed75fbe80436d5c9b5cc632fd2fffbb81f316962b94751411a40cf28efe7152af0a6d55c950ed82
6
+ metadata.gz: 4360d48ffd69e8274b6e2e504547ce58847fa98ba153aedaf7c9e959c4690ac87a01131b006aa023d9b5f0485c2e609cb200d4e6031b5ba7b52c208bef9907ab
7
+ data.tar.gz: 20133bd94e537603a00de43971cd840a46d3212d8a046b115a0177801df7ff28299391c126cebafb550849088996fb0ded7ad25af552535c2c758e0ceaae902b
data/README.md CHANGED
@@ -7,9 +7,8 @@ request that the rate limiter sees logs a new item in the redis store. If
7
7
  more than the allowable number of requests have been made in the given time
8
8
  then no new item is logged and the request is aborted. The items stored in
9
9
  redis include a bucket name and timestamp in their key name. This allows
10
- multiple limit "buckets" to be used and for variable rate limits to be
11
- applied to different requests using the same bucket. See the _Usage_ section
12
- below for examples demonstrating this.
10
+ multiple "buckets" to be used and for variable rate limits to be applied to
11
+ different requests using the same bucket.
13
12
 
14
13
  ## Installing
15
14
 
@@ -23,8 +22,26 @@ below for examples demonstrating this.
23
22
  * Require and enable it in your app after including Sinatra
24
23
 
25
24
  ```ruby
25
+ require 'sinatra'
26
26
  require 'sinatra/rate-limiter'
27
+
27
28
  enable :rate_limiter
29
+
30
+ ...
31
+ ```
32
+
33
+ * Modular applications must explicitly register the extension, e.g.
34
+
35
+ ```ruby
36
+ require 'sinatra/base'
37
+ require 'sinatra/rate-limiter'
38
+
39
+ class ModularApp < Sinatra::Base
40
+ register Sinatra::RateLimiter
41
+ enable :rate_limiter
42
+
43
+ ...
44
+ end
28
45
  ```
29
46
 
30
47
  ## Usage
@@ -34,13 +51,97 @@ in a `before` filter, or in a Padrino controller, etc. `rate_limit` takes
34
51
  zero to infinite parameters, with the syntax:
35
52
 
36
53
  ```
37
- rate_limit [String], [[<Fixnum>, <Fixnum>], [<Fixnum>, <Fixnum>], ...]
54
+ rate_limit [BucketName], [[<Requests>, <Seconds>], ...], [[<Key>: <Value>], ...]
38
55
  ```
39
56
 
40
- The `String` optionally defines a name for this rate limiter, allowing you
41
- to have multiple rate limits within your app. The following pairs of
57
+ The `String` optionally defines a named bucket. The following pairs of
42
58
  `Fixnum`s define `[requests, seconds]`, allowing you to specify how many
43
- requests per seconds this rate limiter allows.
59
+ requests per seconds are allowed for this route/path. Finally overrides for
60
+ the globally defined default options can be provided.
61
+
62
+ See the _Examples_ section below for usage examples.
63
+
64
+ ## Configuration
65
+
66
+ All configuration is optional. If no default limits are specified here,
67
+ you must specify limits with each call of `rate_limit`
68
+
69
+ ### Defaults
70
+
71
+ ```ruby
72
+ set :rate_limiter_environments, [:production]
73
+ set :rate_limiter_default_limits, [10, 20]
74
+ set :rate_limiter_redis_conn, Redis.new
75
+ set :rate_limiter_redis_namespace, 'rate_limit'
76
+ set :rate_limiter_redis_expires, 24*60*60
77
+
78
+ set :rate_limiter_default_options, {
79
+ error_code: 429,
80
+ error_template: nil,
81
+ send_headers: true,
82
+ identifier: Proc.new{ |request| request.ip }
83
+ }
84
+ ```
85
+
86
+ #### `rate_limiter_environments` (Array)
87
+
88
+ An Array of Rack environments to enable the rate limiter for.
89
+
90
+ #### `rate_limiter_default_limits` (Array)
91
+
92
+ Default limit parameters.
93
+
94
+ #### `rate_limiter_redis_conn` (Redis)
95
+
96
+ Redis connection definition (e.g. global variable pointing at your already
97
+ defined Redis connection, or a ConnectionPool, etc).
98
+
99
+ #### `rate_limiter_redis_namespace` (String)
100
+
101
+ The Redis namespace to use. All keys stored in the Redis store will be
102
+ prefixed with this string plus a forward-slash (`/`).
103
+
104
+ #### `rate_limiter_redis_expires` (Integer)
105
+
106
+ How long keys live in the Redis store for. This must be longer than any
107
+ limiter's longest 'seconds' parameter.
108
+
109
+ #### `rate_limiter_default_options` (Hash)
110
+
111
+ Default options provided to each call of `rate_limit`
112
+
113
+ ##### `error_code` (Integer)
114
+
115
+ The HTTP error code to send to the client when a rate limit is reached.
116
+ Defaults to `429` per [RFC 6585](http://tools.ietf.org/html/rfc6585) but
117
+ you may have your own reasons for wanting to use a different code (like 400
118
+ or 503).
119
+
120
+ ##### `error_template` (String)
121
+
122
+ Defines a template to render when a rate limit is reached (e.g. a nice error
123
+ page or a machine friendly JSON response). Three local variables are
124
+ provided (all Integers):
125
+
126
+ * `requests`: The number of requests that triggered the rate limit.
127
+ * `seconds`: The rate limit period.
128
+ * `try_again`: In how many seconds the client should try again.
129
+
130
+ ##### `send_headers` (Boolean)
131
+
132
+ Whether or not to send `X-RateLimit-` headers to the client with each
133
+ request. A `Retry-After` header is currently always sent when a rate
134
+ limit is reached regardless of this setting.
135
+
136
+ ##### `identifier` (Proc)
137
+
138
+ A `Proc` taking exactly one parameter (`request`) which returns a String
139
+ identifying the client for the purposes of rate limiting. Defaults to the
140
+ clients IP address (from `request.ip`) but you could use the value of a
141
+ cookie, a session ID, username, or anything else accessible from Sinatra's
142
+ `request` object.
143
+
144
+ ## Examples
44
145
 
45
146
  The following route will be limited to 10 requests per minute and 100
46
147
  requests per hour:
@@ -53,25 +154,30 @@ requests per hour:
53
154
  end
54
155
  ```
55
156
 
56
- The following will apply an unnamed limit of 1000 requests per hour to all
57
- routes and stricter individual rate limits to two particular routes:
157
+ The following will apply a limit of 1000 requests per hour using the default
158
+ bucket to all routes and stricter individual rate limits with additional
159
+ buckets assigned to the remaining routes. It identifiers the client by the
160
+ value of the cookie `userid` instead of by IP address.
58
161
 
59
162
  ```ruby
60
- set :rate_limiter_default_limits, [1000, 60*60]
163
+ set :rate_limiter_default_limits, [1000, 60*60]
164
+ set :rate_limiter_default_options, send_headers: true,
165
+ identifier: Proc.new{|request| request.cookies['userid']}
166
+
61
167
  before do
62
168
  rate_limit
63
169
  end
64
170
 
65
171
  get '/' do
66
- "this route has the global limit applied"
172
+ "this route has only the global limit applied"
67
173
  end
68
174
 
69
175
  get '/rate-limit-1/example-1' do
70
176
  rate_limit 'ratelimit1', 2, 5,
71
- 10, 60
177
+ 10, 60
72
178
 
73
179
  "this route is rate limited to 2 requests per 5 seconds and 10 per 60
74
- seconds"
180
+ seconds in addition to the global limit of 1000 per hour"
75
181
  end
76
182
 
77
183
  get '/rate-limit-1/example-2' do
@@ -81,34 +187,25 @@ routes and stricter individual rate limits to two particular routes:
81
187
  bucket as '/rate-limit-1'. "
82
188
 
83
189
  get '/rate-limit-2' do
84
- rate_limit 'ratelimit2', 1, 10
190
+ rate_limit 'ratelimit2', 1, 10, send_headers: false
85
191
 
86
- "this route is rate limited to 1 request per 10 seconds"
192
+ "this route is rate limited to 1 request per 10 seconds, and won't send
193
+ any headers"
87
194
  end
88
195
  ```
89
196
 
90
197
  N.B. in the last example, be aware that the more specific rate limits do not
91
198
  override any rate limit already defined during route processing, and the
92
- global rate limit will apply additionally. If you call `rate_limit` more
93
- than once with the same (or no) name, it will be double counted.
199
+ first rate limit specified in `before` will apply additionally. If you call
200
+ `rate_limit` more than once with the same (or no) bucket name, the request
201
+ will be double counted in that bucket.
94
202
 
95
- ## Configuration
203
+ ## License
96
204
 
97
- All configuration is optional. If no default limits are specified here,
98
- you must specify limits with each call of `rate_limit`
205
+ MIT license. See [LICENSE](https://github.com/warrenguy/sinatra-rate-limiter/blob/master/LICENSE).
99
206
 
100
- ### Defaults
207
+ ## Author
101
208
 
102
- ```ruby
103
- set :rate_limiter_default_limits, []
104
- set :rate_limiter_environments, [:production]
105
- set :rate_limiter_error_code, 429
106
- set :rate_limiter_error_template, nil
107
- set :rate_limiter_send_headers, true
108
- set :rate_limiter_custom_user_id, nil
109
- set :rate_limiter_redis_conn, Redis.new
110
- set :rate_limiter_redis_namespace, 'rate_limit'
111
- set :rate_limiter_redis_expires, 24*60*60
112
- ```
209
+ Warren Guy <warren@guy.net.au>
113
210
 
114
- TODO: document each setting here explicitly
211
+ https://warrenguy.me
@@ -10,50 +10,55 @@ module Sinatra
10
10
  def rate_limit(*args)
11
11
  return unless settings.rate_limiter and settings.rate_limiter_environments.include?(settings.environment)
12
12
 
13
- limit_name, limits = parse_args(args)
13
+ bucket, options, limits = parse_args(args)
14
14
 
15
- limiter = RateLimit.new(limit_name, limits)
16
- limiter.settings = settings
17
- limiter.request = request
15
+ limiter = RateLimit.new(bucket, limits)
16
+ limiter.settings = settings
17
+ limiter.request = request
18
+ limiter.options = options
18
19
 
19
20
  if error_locals = limits_exceeded?(limits, limiter)
20
- rate_limit_headers(limits, limit_name, limiter)
21
- response.headers['Retry-After'] = error_locals[:try_again] if settings.rate_limiter_send_headers
22
- halt settings.rate_limiter_error_code, error_response(error_locals)
21
+ rate_limit_headers(limits, bucket, limiter) if limiter.options.send_headers
22
+ response.headers['Retry-After'] = error_locals[:try_again]
23
+ halt limiter.options.error_code, error_response(error_locals, limiter)
23
24
  end
24
25
 
25
- redis.setex([namespace,user_identifier,limit_name,Time.now.to_f.to_s].join('/'),
26
- settings.rate_limiter_redis_expires,
27
- request.env['REQUEST_URI'])
26
+ redis(limiter).setex([namespace(limiter),user_identifier(limiter),bucket,Time.now.to_f.to_s].join('/'),
27
+ settings.rate_limiter_redis_expires,
28
+ request.env['REQUEST_URI'])
28
29
 
29
- rate_limit_headers(limits, limit_name, limiter) if settings.rate_limiter_send_headers
30
+ rate_limit_headers(limits, bucket, limiter) if limiter.options.send_headers
30
31
  end
31
32
 
32
33
  private
33
34
 
34
35
  def parse_args(args)
35
- limit_name = args.map{|a| a.class}.first.eql?(String) ? args.shift : 'default'
36
- args = settings.rate_limiter_default_limits if args.size < 1
36
+ bucket = (args.first.class == String) ? args.shift : 'default'
37
+ options = (args.last.class == Hash) ? args.pop : {}
38
+ limits = (args.size < 1) ? settings.rate_limiter_default_limits : args
37
39
 
38
- if (args.size < 1)
40
+ if (limits.size < 1)
39
41
  raise ArgumentError, 'No explicit or default limits values provided.'
40
- elsif (args.map{|a| a.class}.select{|a| a != Fixnum}.count > 0)
42
+ elsif (limits.map{|a| a.class}.select{|a| a != Fixnum}.count > 0)
41
43
  raise ArgumentError, 'Non-Fixnum parameters supplied. All parameters must be Fixnum except the first which may be a String.'
42
- elsif ((args.map{|a| a.class}.size % 2) != 0)
44
+ elsif ((limits.map{|a| a.class}.size % 2) != 0)
43
45
  raise ArgumentError, 'Wrong number of Fixnum parameters supplied.'
44
- elsif !(limit_name =~ /^[a-zA-Z0-9\-]*$/)
46
+ elsif !(bucket =~ /^[a-zA-Z0-9\-]*$/)
45
47
  raise ArgumentError, 'Limit name must be a String containing only a-z, A-Z, 0-9, and -.'
48
+ elsif (omap = (options.keys.map{|o| settings.rate_limiter_default_options.keys.include?(o)})).include?(false)
49
+ raise ArgumentError, "Invalid option '#{options.keys[omap.index(false)]}'."
46
50
  end
47
51
 
48
- return [limit_name,
49
- args.each_slice(2).map{|a| {requests: a[0], seconds: a[1]}}]
52
+ return [bucket,
53
+ options,
54
+ limits.each_slice(2).map{|a| {requests: a[0], seconds: a[1]}}]
50
55
  end
51
56
 
52
- def redis
57
+ def redis(limiter)
53
58
  settings.rate_limiter_redis_conn
54
59
  end
55
60
 
56
- def namespace
61
+ def namespace(limiter)
57
62
  settings.rate_limiter_redis_namespace
58
63
  end
59
64
 
@@ -74,8 +79,8 @@ module Sinatra
74
79
  end
75
80
  end
76
81
 
77
- def rate_limit_headers(limits, limit_name, limiter)
78
- header_prefix = 'X-Rate-Limit' + (limit_name.eql?('default') ? '' : '-' + limit_name)
82
+ def rate_limit_headers(limits, bucket, limiter)
83
+ header_prefix = 'X-Rate-Limit' + (bucket.eql?('default') ? '' : '-' + bucket)
79
84
  limit_no = 0 if limits.length > 1
80
85
  limits.each do |limit|
81
86
  limit_no = limit_no + 1 if limit_no
@@ -85,18 +90,18 @@ module Sinatra
85
90
  end
86
91
  end
87
92
 
88
- def error_response(locals)
89
- if settings.rate_limiter_error_template
90
- render settings.rate_limiter_error_template, locals: locals
93
+ def error_response(locals, limiter)
94
+ if limiter.options.error_template
95
+ render limiter.options.error_template, locals: locals
91
96
  else
92
97
  content_type 'text/plain'
93
98
  "Rate limit exceeded (#{locals[:requests]} requests in #{locals[:seconds]} seconds). Try again in #{locals[:try_again]} seconds."
94
99
  end
95
100
  end
96
101
 
97
- def user_identifier
98
- if settings.rate_limiter_custom_user_id.class == Proc
99
- return settings.rate_limiter_custom_user_id.call(request)
102
+ def user_identifier(limiter)
103
+ if limiter.options.identifier.class == Proc
104
+ return limiter.options.identifier.call(request)
100
105
  else
101
106
  return request.ip
102
107
  end
@@ -115,26 +120,27 @@ module Sinatra
115
120
  app.helpers RateLimiter::Helpers
116
121
 
117
122
  app.set :rate_limiter, false
118
- app.set :rate_limiter_default_limits, [] # 10 requests per minute: [{requests: 10, seconds: 60}]
119
123
  app.set :rate_limiter_environments, [:production]
120
- app.set :rate_limiter_error_code, 429 # http://tools.ietf.org/html/rfc6585
121
- app.set :rate_limiter_error_template, nil # locals: requests, seconds, try_again
122
- app.set :rate_limiter_send_headers, true
123
- app.set :rate_limiter_custom_user_id, nil # Proc.new { Proc.new{ |request| request.ip } }
124
- # must be wrapped with another Proc because Sinatra
125
- # evaluates Procs in settings when reading them.
124
+ app.set :rate_limiter_default_limits, [10, 20] # 10 requests per 20 seconds
126
125
  app.set :rate_limiter_redis_conn, Redis.new
127
126
  app.set :rate_limiter_redis_namespace, 'rate_limit'
128
127
  app.set :rate_limiter_redis_expires, 24*60*60 # This must be larger than longest limit time period
128
+
129
+ app.set :rate_limiter_default_options, {
130
+ error_code: 429,
131
+ error_template: nil,
132
+ send_headers: true,
133
+ identifier: Proc.new{ |request| request.ip }
134
+ }
129
135
  end
130
136
 
131
137
  end
132
138
 
133
139
  class RateLimit
134
- attr_reader :history
140
+ attr_reader :history, :options
135
141
 
136
- def initialize(limit_name, limits)
137
- @limit_name = limit_name
142
+ def initialize(bucket, limits)
143
+ @bucket = bucket
138
144
  @limits = limits
139
145
  @time_prefix = get_min_time_prefix(@limits)
140
146
  end
@@ -149,12 +155,19 @@ module Sinatra
149
155
  if @history
150
156
  @history
151
157
  else
152
- @history = redis.
153
- keys("#{[namespace,user_identifier,@limit_name].join('/')}/#{@time_prefix}*").
158
+ @history = redis(self).
159
+ keys("#{[namespace(self),user_identifier(self),@bucket].join('/')}/#{@time_prefix}*").
154
160
  map{|k| k.split('/')[3].to_f}
155
161
  end
156
162
  end
157
163
 
164
+ def options=(options)
165
+ @options = OpenStruct.new(settings.rate_limiter_default_options.merge(options))
166
+ end
167
+ def options
168
+ @options
169
+ end
170
+
158
171
  def settings=(settings)
159
172
  @settings = settings
160
173
  end
@@ -170,4 +183,5 @@ module Sinatra
170
183
  end
171
184
  end
172
185
 
186
+ register RateLimiter
173
187
  end
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.2.2
4
+ version: 0.3.1
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-28 00:00:00.000000000 Z
11
+ date: 2015-05-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: sinatra