sinatra-rate-limiter 0.2.2 → 0.3.1
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.
- 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
|