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.
- checksums.yaml +4 -4
- data/README.md +30 -21
- data/lib/sinatra/rate-limiter.rb +76 -82
- 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: 75be17ee12b4828c4e6640da0d910329df557225
|
4
|
+
data.tar.gz: fc79ea5b2aae041c72d8e1b4af609a19efa8ce09
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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.
|
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
|
data/lib/sinatra/rate-limiter.rb
CHANGED
@@ -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
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
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
|
-
|
28
|
-
|
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
|
38
|
-
options
|
39
|
-
limits
|
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
|
-
|
50
|
-
|
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
|
-
|
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
|
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
|
-
|
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
|
170
|
-
|
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
|
-
|
174
|
-
settings.rate_limiter_redis_namespace
|
175
|
-
end
|
158
|
+
private
|
176
159
|
|
177
|
-
def
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
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
|
186
|
-
@
|
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
|
-
|
189
|
-
|
177
|
+
|
178
|
+
def redis
|
179
|
+
@settings.rate_limiter_redis_conn
|
190
180
|
end
|
191
181
|
|
192
|
-
def
|
193
|
-
@
|
182
|
+
def namespace
|
183
|
+
@settings.rate_limiter_redis_namespace
|
194
184
|
end
|
195
|
-
|
196
|
-
|
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.
|
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-
|
11
|
+
date: 2015-06-01 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: sinatra
|