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.
- checksums.yaml +4 -4
- data/README.md +130 -33
- data/lib/sinatra/rate-limiter.rb +55 -41
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 006ef1d8689384395b156b7c636c4745287d07f0
|
4
|
+
data.tar.gz: 127f8f5d68dcd6898e8d21e6ac0fdfbaf16858b3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
11
|
-
|
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 [
|
54
|
+
rate_limit [BucketName], [[<Requests>, <Seconds>], ...], [[<Key>: <Value>], ...]
|
38
55
|
```
|
39
56
|
|
40
|
-
The `String` optionally defines a
|
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
|
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
|
57
|
-
routes and stricter individual rate limits
|
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,
|
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
|
-
|
93
|
-
than once with the same (or no) name,
|
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
|
-
##
|
203
|
+
## License
|
96
204
|
|
97
|
-
|
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
|
-
|
207
|
+
## Author
|
101
208
|
|
102
|
-
|
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
|
-
|
211
|
+
https://warrenguy.me
|
data/lib/sinatra/rate-limiter.rb
CHANGED
@@ -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
|
-
|
13
|
+
bucket, options, limits = parse_args(args)
|
14
14
|
|
15
|
-
limiter = RateLimit.new(
|
16
|
-
limiter.settings
|
17
|
-
limiter.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,
|
21
|
-
response.headers['Retry-After'] = error_locals[:try_again]
|
22
|
-
halt
|
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,
|
26
|
-
|
27
|
-
|
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,
|
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
|
-
|
36
|
-
|
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 (
|
40
|
+
if (limits.size < 1)
|
39
41
|
raise ArgumentError, 'No explicit or default limits values provided.'
|
40
|
-
elsif (
|
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 ((
|
44
|
+
elsif ((limits.map{|a| a.class}.size % 2) != 0)
|
43
45
|
raise ArgumentError, 'Wrong number of Fixnum parameters supplied.'
|
44
|
-
elsif !(
|
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 [
|
49
|
-
|
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,
|
78
|
-
header_prefix = 'X-Rate-Limit' + (
|
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
|
90
|
-
render
|
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
|
99
|
-
return
|
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 :
|
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(
|
137
|
-
@
|
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,@
|
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.
|
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-
|
11
|
+
date: 2015-05-30 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: sinatra
|