sinatra-rate-limiter 0.3.2 → 0.4.0
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 +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
|