sinatra-rate-limiter 0.2.1 → 0.2.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +31 -4
- data/lib/sinatra/rate-limiter.rb +69 -24
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 284920f0a11fd397c41ae1bd5e31f6e1f45bb846
|
4
|
+
data.tar.gz: 58c8cc97bbab02f53ba781fbc4e1ef0ba0901eb5
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 589661c3cd2635d4fad8c7ad421ef3ca02cc22c6dff9e7bcbb6dc495820d99c2b1063a530057907499a2725404c198cbf394d38a34362fd9dcc1e4afbe670554
|
7
|
+
data.tar.gz: d279915ae972605f568070b3e0d63b2a35963b5a805dc82c3ed75fbe80436d5c9b5cc632fd2fffbb81f316962b94751411a40cf28efe7152af0a6d55c950ed82
|
data/README.md
CHANGED
@@ -2,6 +2,15 @@
|
|
2
2
|
|
3
3
|
A customisable redis backed rate limiter for Sinatra applications.
|
4
4
|
|
5
|
+
This rate limiter extension operates on a leaky bucket principle. Each
|
6
|
+
request that the rate limiter sees logs a new item in the redis store. If
|
7
|
+
more than the allowable number of requests have been made in the given time
|
8
|
+
then no new item is logged and the request is aborted. The items stored in
|
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.
|
13
|
+
|
5
14
|
## Installing
|
6
15
|
|
7
16
|
* Add the gem to your Gemfile
|
@@ -24,7 +33,14 @@ Use `rate_limit` in the pipeline of any route (i.e. in the route itself, or
|
|
24
33
|
in a `before` filter, or in a Padrino controller, etc. `rate_limit` takes
|
25
34
|
zero to infinite parameters, with the syntax:
|
26
35
|
|
27
|
-
```
|
36
|
+
```
|
37
|
+
rate_limit [String], [[<Fixnum>, <Fixnum>], [<Fixnum>, <Fixnum>], ...]
|
38
|
+
```
|
39
|
+
|
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
|
42
|
+
`Fixnum`s define `[requests, seconds]`, allowing you to specify how many
|
43
|
+
requests per seconds this rate limiter allows.
|
28
44
|
|
29
45
|
The following route will be limited to 10 requests per minute and 100
|
30
46
|
requests per hour:
|
@@ -50,20 +66,31 @@ routes and stricter individual rate limits to two particular routes:
|
|
50
66
|
"this route has the global limit applied"
|
51
67
|
end
|
52
68
|
|
53
|
-
get '/rate-limit-1' do
|
69
|
+
get '/rate-limit-1/example-1' do
|
54
70
|
rate_limit 'ratelimit1', 2, 5,
|
55
71
|
10, 60
|
72
|
+
|
73
|
+
"this route is rate limited to 2 requests per 5 seconds and 10 per 60
|
74
|
+
seconds"
|
56
75
|
end
|
57
76
|
|
77
|
+
get '/rate-limit-1/example-2' do
|
78
|
+
rate_limit 'ratelimit1', 60, 60
|
79
|
+
|
80
|
+
"this route is rate limited to 60 requests per minute using the same
|
81
|
+
bucket as '/rate-limit-1'. "
|
82
|
+
|
58
83
|
get '/rate-limit-2' do
|
59
84
|
rate_limit 'ratelimit2', 1, 10
|
85
|
+
|
86
|
+
"this route is rate limited to 1 request per 10 seconds"
|
60
87
|
end
|
61
88
|
```
|
62
89
|
|
63
90
|
N.B. in the last example, be aware that the more specific rate limits do not
|
64
91
|
override any rate limit already defined during route processing, and the
|
65
|
-
global rate limit will apply additionally. If you call `rate_limit` more
|
66
|
-
once with the same (or no) name, it will be double counted.
|
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.
|
67
94
|
|
68
95
|
## Configuration
|
69
96
|
|
data/lib/sinatra/rate-limiter.rb
CHANGED
@@ -12,8 +12,12 @@ module Sinatra
|
|
12
12
|
|
13
13
|
limit_name, limits = parse_args(args)
|
14
14
|
|
15
|
-
|
16
|
-
|
15
|
+
limiter = RateLimit.new(limit_name, limits)
|
16
|
+
limiter.settings = settings
|
17
|
+
limiter.request = request
|
18
|
+
|
19
|
+
if error_locals = limits_exceeded?(limits, limiter)
|
20
|
+
rate_limit_headers(limits, limit_name, limiter)
|
17
21
|
response.headers['Retry-After'] = error_locals[:try_again] if settings.rate_limiter_send_headers
|
18
22
|
halt settings.rate_limiter_error_code, error_response(error_locals)
|
19
23
|
end
|
@@ -22,7 +26,7 @@ module Sinatra
|
|
22
26
|
settings.rate_limiter_redis_expires,
|
23
27
|
request.env['REQUEST_URI'])
|
24
28
|
|
25
|
-
rate_limit_headers(limits, limit_name) if settings.rate_limiter_send_headers
|
29
|
+
rate_limit_headers(limits, limit_name, limiter) if settings.rate_limiter_send_headers
|
26
30
|
end
|
27
31
|
|
28
32
|
private
|
@@ -37,11 +41,12 @@ module Sinatra
|
|
37
41
|
raise ArgumentError, 'Non-Fixnum parameters supplied. All parameters must be Fixnum except the first which may be a String.'
|
38
42
|
elsif ((args.map{|a| a.class}.size % 2) != 0)
|
39
43
|
raise ArgumentError, 'Wrong number of Fixnum parameters supplied.'
|
44
|
+
elsif !(limit_name =~ /^[a-zA-Z0-9\-]*$/)
|
45
|
+
raise ArgumentError, 'Limit name must be a String containing only a-z, A-Z, 0-9, and -.'
|
40
46
|
end
|
41
47
|
|
42
|
-
|
43
|
-
|
44
|
-
return [limit_name, limits]
|
48
|
+
return [limit_name,
|
49
|
+
args.each_slice(2).map{|a| {requests: a[0], seconds: a[1]}}]
|
45
50
|
end
|
46
51
|
|
47
52
|
def redis
|
@@ -52,38 +57,31 @@ module Sinatra
|
|
52
57
|
settings.rate_limiter_redis_namespace
|
53
58
|
end
|
54
59
|
|
55
|
-
def
|
56
|
-
|
57
|
-
keys("#{[namespace,user_identifier,limit_name].join('/')}/*").
|
58
|
-
map{|k| k.split('/')[3].to_f}.
|
59
|
-
select{|t| seconds.eql?(0) ? true : t > (Time.now.to_f - seconds)}
|
60
|
-
end
|
61
|
-
|
62
|
-
def limit_remaining(limit, limit_name)
|
63
|
-
limit[:requests] - limit_history(limit_name, limit[:seconds]).length
|
60
|
+
def limit_remaining(limit, limiter)
|
61
|
+
limit[:requests] - limiter.history(limit[:seconds]).length
|
64
62
|
end
|
65
63
|
|
66
|
-
def limit_reset(limit,
|
67
|
-
limit[:seconds] - (Time.now.to_f -
|
64
|
+
def limit_reset(limit, limiter)
|
65
|
+
limit[:seconds] - (Time.now.to_f - limiter.history(limit[:seconds]).first.to_f).to_i
|
68
66
|
end
|
69
67
|
|
70
|
-
def limits_exceeded?(limits,
|
71
|
-
exceeded = limits.select {|limit| limit_remaining(limit,
|
68
|
+
def limits_exceeded?(limits, limiter)
|
69
|
+
exceeded = limits.select {|limit| limit_remaining(limit, limiter) < 1}.sort_by{|e| e[:seconds]}.last
|
72
70
|
|
73
71
|
if exceeded
|
74
|
-
try_again = limit_reset(exceeded,
|
72
|
+
try_again = limit_reset(exceeded, limiter)
|
75
73
|
return exceeded.merge({try_again: try_again.to_i})
|
76
74
|
end
|
77
75
|
end
|
78
76
|
|
79
|
-
def rate_limit_headers(limits, limit_name)
|
77
|
+
def rate_limit_headers(limits, limit_name, limiter)
|
80
78
|
header_prefix = 'X-Rate-Limit' + (limit_name.eql?('default') ? '' : '-' + limit_name)
|
81
79
|
limit_no = 0 if limits.length > 1
|
82
80
|
limits.each do |limit|
|
83
81
|
limit_no = limit_no + 1 if limit_no
|
84
82
|
response.headers[header_prefix + (limit_no ? "-#{limit_no}" : '') + '-Limit'] = limit[:requests]
|
85
|
-
response.headers[header_prefix + (limit_no ? "-#{limit_no}" : '') + '-Remaining'] = limit_remaining(limit,
|
86
|
-
response.headers[header_prefix + (limit_no ? "-#{limit_no}" : '') + '-Reset'] = limit_reset(limit,
|
83
|
+
response.headers[header_prefix + (limit_no ? "-#{limit_no}" : '') + '-Remaining'] = limit_remaining(limit, limiter)
|
84
|
+
response.headers[header_prefix + (limit_no ? "-#{limit_no}" : '') + '-Reset'] = limit_reset(limit, limiter)
|
87
85
|
end
|
88
86
|
end
|
89
87
|
|
@@ -104,6 +102,13 @@ module Sinatra
|
|
104
102
|
end
|
105
103
|
end
|
106
104
|
|
105
|
+
def get_min_time_prefix(limits)
|
106
|
+
now = Time.now.to_f
|
107
|
+
oldest = Time.now.to_f - limits.sort_by{|l| -l[:seconds]}.first[:seconds]
|
108
|
+
|
109
|
+
return now.to_s[0..((now/oldest).to_s.split(/^1\.|[1-9]+/)[1].length)].to_i.to_s
|
110
|
+
end
|
111
|
+
|
107
112
|
end
|
108
113
|
|
109
114
|
def self.registered(app)
|
@@ -120,9 +125,49 @@ module Sinatra
|
|
120
125
|
# evaluates Procs in settings when reading them.
|
121
126
|
app.set :rate_limiter_redis_conn, Redis.new
|
122
127
|
app.set :rate_limiter_redis_namespace, 'rate_limit'
|
123
|
-
app.set :rate_limiter_redis_expires, 24*60*60
|
128
|
+
app.set :rate_limiter_redis_expires, 24*60*60 # This must be larger than longest limit time period
|
129
|
+
end
|
130
|
+
|
131
|
+
end
|
132
|
+
|
133
|
+
class RateLimit
|
134
|
+
attr_reader :history
|
135
|
+
|
136
|
+
def initialize(limit_name, limits)
|
137
|
+
@limit_name = limit_name
|
138
|
+
@limits = limits
|
139
|
+
@time_prefix = get_min_time_prefix(@limits)
|
124
140
|
end
|
125
141
|
|
142
|
+
include Sinatra::RateLimiter::Helpers
|
143
|
+
|
144
|
+
def history(seconds=0)
|
145
|
+
redis_history.select{|t| seconds.eql?(0) ? true : t > (Time.now.to_f - seconds)}
|
146
|
+
end
|
147
|
+
|
148
|
+
def redis_history
|
149
|
+
if @history
|
150
|
+
@history
|
151
|
+
else
|
152
|
+
@history = redis.
|
153
|
+
keys("#{[namespace,user_identifier,@limit_name].join('/')}/#{@time_prefix}*").
|
154
|
+
map{|k| k.split('/')[3].to_f}
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
def settings=(settings)
|
159
|
+
@settings = settings
|
160
|
+
end
|
161
|
+
def settings
|
162
|
+
@settings
|
163
|
+
end
|
164
|
+
|
165
|
+
def request=(request)
|
166
|
+
@request = request
|
167
|
+
end
|
168
|
+
def request
|
169
|
+
@request
|
170
|
+
end
|
126
171
|
end
|
127
172
|
|
128
173
|
end
|