sinatra-rate-limiter 0.2.1 → 0.2.2
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 +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
|