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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +31 -4
  3. data/lib/sinatra/rate-limiter.rb +69 -24
  4. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 030b21e819260562c4da8c1472360e4d8cdb1c8d
4
- data.tar.gz: 456ad3d77b05fe72a5d01aba6ee3699b7da49916
3
+ metadata.gz: 284920f0a11fd397c41ae1bd5e31f6e1f45bb846
4
+ data.tar.gz: 58c8cc97bbab02f53ba781fbc4e1ef0ba0901eb5
5
5
  SHA512:
6
- metadata.gz: 920f5b487a6dd84cd220a27869811d834102ff591a68d2446758b851546893af17f1e1dbdf8fbb9b44f9bf88547089263bc30c0af137c786b3bc2db710cd2303
7
- data.tar.gz: 14427e370cc5f040287edfba31247480fac29bb2c66a7b06199df0e7f778f9c75c35cb840b926253bca41fe17c3748cabc65dd1c88076fbf50cfdd53a0b36a72
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
- ```rate_limit [String], [<Fixnum>, <Fixnum>], [<Fixnum>, <Fixnum>], ...```
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 than
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
 
@@ -12,8 +12,12 @@ module Sinatra
12
12
 
13
13
  limit_name, limits = parse_args(args)
14
14
 
15
- if error_locals = limits_exceeded?(limits, limit_name)
16
- rate_limit_headers(limits, limit_name)
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
- limits = args.each_slice(2).to_a.map{|a| {requests: a[0], seconds: a[1]}}
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 limit_history(limit_name, seconds=0)
56
- redis.
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, limit_name)
67
- limit[:seconds] - (Time.now.to_f - limit_history(limit_name, limit[:seconds]).first).to_i
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, limit_name)
71
- exceeded = limits.select {|limit| limit_remaining(limit, limit_name) < 1}.sort_by{|e| e[:seconds]}.last
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, limit_name)
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, limit_name)
86
- response.headers[header_prefix + (limit_no ? "-#{limit_no}" : '') + '-Reset'] = limit_reset(limit, limit_name)
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sinatra-rate-limiter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Warren Guy