dalli-rate_limiter 0.2.0 → 0.3.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 +91 -51
- data/dalli-rate_limiter.gemspec +0 -1
- data/lib/dalli/rate_limiter.rb +10 -11
- data/lib/dalli/rate_limiter/version.rb +1 -1
- metadata +2 -16
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 192caf393892d4ebed3b31ea0a9723eb7c6b5f65
|
4
|
+
data.tar.gz: 7a968b2e507d1b264de374e05089a49a08a9bd46
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 74b36d9bf284fe2aa66cced426037111c568279acf1662348d52ed8d18061e408d362979fbddfd87311f4feb83b5edb44d67f62941abacc306368c9f34ea8d38
|
7
|
+
data.tar.gz: 8e3b10b300b5d022649a26b42d68e926f82e852e382b57c446138eb8e10cdc333b0d3eac8f8677542fb2a42562e2620ef5e61bcd651d087bc87aac8bc2d3b124
|
data/README.md
CHANGED
@@ -3,10 +3,10 @@
|
|
3
3
|
**Dalli::RateLimiter** provides arbitrary [Memcached][6]-backed rate limiting
|
4
4
|
for your Ruby applications. You may be using an application-level rate limiter
|
5
5
|
such as [Rack::Ratelimit][1], [Rack::Throttle][2], or [Rack::Attack][3], or
|
6
|
-
something higher up in your stack (like an
|
6
|
+
something higher up in your stack (like an NGINX zone or HAproxy stick-table).
|
7
7
|
This is not intended to be a replacement for any of those functions. Your
|
8
8
|
application may not even be a web service and yet you find yourself needing to
|
9
|
-
throttle certain types of operations.
|
9
|
+
limit (or throttle) certain types of operations.
|
10
10
|
|
11
11
|
This library allows you to impose specific rate limits on specific functions at
|
12
12
|
whatever granularity you desire. For example, you have a function in your Ruby
|
@@ -17,15 +17,14 @@ limit imposed by the provider for a certain endpoint. It wouldn't make sense to
|
|
17
17
|
apply these limits at the application level—it would be much easier to
|
18
18
|
tightly integrate a check within your business logic.
|
19
19
|
|
20
|
-
**Dalli::RateLimiter** leverages the excellent [Dalli][4] and
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
(see [Block Form](#block-form) below for an example of the differences).
|
20
|
+
**Dalli::RateLimiter** leverages the excellent [Dalli][4] gem for fast and
|
21
|
+
efficient (and thread-safe) Memcached access. It uses an allowance counter and
|
22
|
+
floating timestamp to implement a sliding window for each unique key, enforcing
|
23
|
+
a limit of _m_ requests over a period of _n_ seconds. If you're familiar with
|
24
|
+
[Sidekiq][10] (which is another excellent piece of software, written by the
|
25
|
+
same person who wrote Dalli), it is similar to the Window style of the
|
26
|
+
Sidekiq::Limiter class, although the invocation syntax differs slightly (see
|
27
|
+
[Block Form](#block-form) below for an example of the differences).
|
29
28
|
|
30
29
|
It supports arbitrary unit quantities of consumption for partial operations or
|
31
30
|
for operations that logically count as more than one request (i.e. batched
|
@@ -40,7 +39,7 @@ performed with floating-point precision.
|
|
40
39
|
Add this line to your application's Gemfile:
|
41
40
|
|
42
41
|
```ruby
|
43
|
-
gem 'dalli-rate_limiter', '~> 0.
|
42
|
+
gem 'dalli-rate_limiter', '~> 0.3.0'
|
44
43
|
```
|
45
44
|
|
46
45
|
And then execute:
|
@@ -63,16 +62,23 @@ def do_foo
|
|
63
62
|
|
64
63
|
# Do foo...
|
65
64
|
end
|
65
|
+
|
66
|
+
def do_bar
|
67
|
+
lim = Dalli::RateLimiter.new
|
68
|
+
|
69
|
+
lim.without_exceeding do
|
70
|
+
# Do bar...
|
71
|
+
end
|
72
|
+
end
|
66
73
|
```
|
67
74
|
|
68
|
-
**Dalli::RateLimiter** will, by default,
|
69
|
-
default options, using a block that yields Dalli::Client instances with its
|
75
|
+
**Dalli::RateLimiter** will, by default, use a Dalli::Client instance with the
|
70
76
|
default options. If `MEMCACHE_SERVERS` is set in your environment, or if your
|
71
77
|
Memcached instance is running on localhost, port 11211, this is the quickest
|
72
78
|
way to get started. Alternatively, you can pass in your own single-threaded
|
73
79
|
Dalli::Client instance—or your own multi-threaded ConnectionPool instance
|
74
|
-
(
|
75
|
-
connection settings. Pass in `nil` to force the default behavior.
|
80
|
+
(see [Compatibility](#compatibility) below)—as the first argument to
|
81
|
+
customize the connection settings. Pass in `nil` to force the default behavior.
|
76
82
|
|
77
83
|
The library itself defaults to five (5) requests per eight (8) seconds, but
|
78
84
|
these can easily be changed with the `:max_requests` and `:period` options.
|
@@ -80,19 +86,19 @@ Locking can be fine-tuned by setting the `:lock_timeout` option. A
|
|
80
86
|
`:key_prefix` option can be specified as well; note that this will be used in
|
81
87
|
combination with any `:namespace` option defined in the Dalli::Client.
|
82
88
|
|
83
|
-
The **Dalli::RateLimiter** instance itself is not stateful
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
+
The **Dalli::RateLimiter** instance itself is not stateful (in that it doesn't
|
90
|
+
track the state of the things being limited, only the parameters of the limit
|
91
|
+
itself), so it can be instantiated as needed (e.g. in a function definition) or
|
92
|
+
in a more global scope (e.g. in a Rails initializer). It does not mutate any of
|
93
|
+
its own attributes or allow its attributes to be mutated so it should be safe
|
94
|
+
to share between threads.
|
89
95
|
|
90
96
|
The main instance method, `#exceeded?` will return `false` if the request is
|
91
97
|
free to proceed. If the limit has been exceeded, it will return a positive
|
92
98
|
floating point value that represents the fractional number of seconds that the
|
93
99
|
caller should wait until retrying the request. Assuming no other requests were
|
94
100
|
process during that time, the retried request will be free to proceed at that
|
95
|
-
point.
|
101
|
+
point. When invoking this method, please be sure to pass in a key that is
|
96
102
|
unique (in combination with the `:key_prefix` option described above) to the
|
97
103
|
thing you are trying to limit. An optional second argument specifies the number
|
98
104
|
of requests to "consume" from the allowance; this defaults to one (1).
|
@@ -114,45 +120,69 @@ the lock.
|
|
114
120
|
## Advanced Usage
|
115
121
|
|
116
122
|
```ruby
|
123
|
+
require "connection_pool"
|
124
|
+
|
117
125
|
dalli = ConnectionPool.new(:size => 5, :timeout => 3) {
|
118
|
-
Dalli::Client.new
|
126
|
+
Dalli::Client.new nil, :namespace => "myapp"
|
119
127
|
}
|
120
128
|
|
121
|
-
|
122
|
-
:key_prefix => "username-
|
129
|
+
USERNAME_LIMIT = Dalli::RateLimiter.new dalli,
|
130
|
+
:key_prefix => "username-limit", :max_requests => 2, :period => 3_600
|
123
131
|
|
124
|
-
|
125
|
-
:key_prefix => "widgets-
|
132
|
+
WIDGETS_LIMIT = Dalli::RateLimiter.new dalli,
|
133
|
+
:key_prefix => "widgets-limit", :max_requests => 10, :period => 60
|
126
134
|
|
127
135
|
def change_username(user_id, new_username)
|
128
|
-
if
|
136
|
+
if USERNAME_LIMIT.exceeded?(user_id) # user-specific limit on changing usernames
|
129
137
|
halt 422, "Sorry! Only two username changes allowed per hour."
|
130
138
|
end
|
131
139
|
|
132
140
|
# Change username...
|
133
141
|
rescue Dalli::RateLimiter::LockError
|
134
|
-
# Unable to acquire a lock...
|
142
|
+
# Unable to acquire a lock before lock timeout...
|
135
143
|
end
|
136
144
|
|
137
|
-
def add_widgets(
|
138
|
-
if some_widgets.length >
|
145
|
+
def add_widgets(some_widgets)
|
146
|
+
if some_widgets.length > WIDGETS_LIMIT.max_requests
|
139
147
|
halt 400, "Too many widgets!"
|
140
148
|
end
|
141
149
|
|
142
|
-
if time =
|
150
|
+
if time = WIDGETS_LIMIT.exceeded?(nil, some_widgets.length) # global limit on adding widgets
|
143
151
|
halt 422, "Sorry! Unable to process request. " \
|
144
152
|
"Please wait at least #{time} seconds before trying again."
|
145
153
|
end
|
146
154
|
|
147
155
|
# Add widgets...
|
148
156
|
rescue Dalli::RateLimiter::LockError
|
149
|
-
# Unable to acquire a lock...
|
157
|
+
# Unable to acquire a lock before lock timeout...
|
150
158
|
end
|
151
159
|
```
|
152
160
|
|
153
161
|
## Block Form
|
154
162
|
|
155
|
-
|
163
|
+
This alternative syntax will sleep (as necessary) until the request can be
|
164
|
+
processed without exceeding the limit. An optional wait timout can be specified
|
165
|
+
to prevent the method from sleeping forever. Rewriting the `add_widgets` method
|
166
|
+
from above:
|
167
|
+
|
168
|
+
```ruby
|
169
|
+
def add_widgets(some_widgets)
|
170
|
+
if some_widgets.length > WIDGETS_LIMIT.max_requests
|
171
|
+
halt 400, "Too many widgets!"
|
172
|
+
end
|
173
|
+
|
174
|
+
WIDGETS_LIMIT.without_exceeding(nil, some_widgets.length, :wait_timeout => 30) do
|
175
|
+
# Add widgets...
|
176
|
+
end
|
177
|
+
rescue Dalli::RateLimiter::LimitError
|
178
|
+
halt 422, "Sorry! Request timed out. Please try again later."
|
179
|
+
rescue Dalli::RateLimiter::LockError
|
180
|
+
# Unable to acquire a lock before lock timeout...
|
181
|
+
end
|
182
|
+
```
|
183
|
+
|
184
|
+
This feature was originally requested for parity with Sidekiq::Limiter, so
|
185
|
+
here's an example adapted from Sidekiq::Limiter.window's [documentation][9]:
|
156
186
|
|
157
187
|
```ruby
|
158
188
|
def perform(user_id)
|
@@ -165,7 +195,7 @@ def perform(user_id)
|
|
165
195
|
rescue Dalli::RateLimiter::LimitError
|
166
196
|
# Unable to execute block before wait timeout...
|
167
197
|
rescue Dalli::RateLimiter::LockError
|
168
|
-
# Unable to acquire a lock...
|
198
|
+
# Unable to acquire a lock before lock timeout...
|
169
199
|
end
|
170
200
|
```
|
171
201
|
|
@@ -175,8 +205,8 @@ end results. Or, likewise, you could set `:key_prefix` to `"stripe:#{user_id}"`
|
|
175
205
|
and pass in `nil` as the first argument to `#without_exceeding`. Sometimes it
|
176
206
|
makes sense to share an instance between method calls, or indeed between
|
177
207
|
different methods, and sometimes it doesn't. Please note that if `:key_prefix`
|
178
|
-
and the first argument to `#
|
179
|
-
Dalli::Client will abort with an ArgumentError ("key cannot be blank").
|
208
|
+
and the first argument to `#without_exceeding` (or `#exceeded?`) are both
|
209
|
+
`nil`, Dalli::Client will abort with an ArgumentError ("key cannot be blank").
|
180
210
|
|
181
211
|
## Compatibility
|
182
212
|
|
@@ -184,7 +214,13 @@ Dalli::Client will abort with an ArgumentError ("key cannot be blank").
|
|
184
214
|
tested with frozen string literals under Ruby 2.3.0. It has also been tested
|
185
215
|
under Rubinius 2.15 and 3.14, and JRuby 1.7 (in 1.9.3 execution mode) and 9K.
|
186
216
|
|
187
|
-
|
217
|
+
If you are sharing a **Dalli::RateLimiter** instance between multiple threads
|
218
|
+
and performance is a concern, you might consider adding the
|
219
|
+
[connection_pool][5] gem to your project and passing in a ConnectionPool
|
220
|
+
instance (wrapping Dalli::Client) as the first argument to the constructor.
|
221
|
+
Make sure your pool has enough slots (`:size`) for these operations; I aim for
|
222
|
+
one slot per thread plus one or two for overhead in my applications. You might
|
223
|
+
also consider adding the [kgio][7] gem to your project to [give Dalli a 10-20%
|
188
224
|
performance boost][8].
|
189
225
|
|
190
226
|
## Caveats
|
@@ -193,23 +229,27 @@ A rate-limiting system is only as good as its backing store, and it should be
|
|
193
229
|
noted that a Memcached ring can lose members or indeed its entire working set
|
194
230
|
(in the event of a flush operation) at the drop of a hat. Mission-critical use
|
195
231
|
cases, where repeated operations absolutely, positively have to be restricted,
|
196
|
-
should probably seek solutions elsewhere.
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
232
|
+
should probably seek solutions elsewhere. If you have already have Redis in
|
233
|
+
your stack, you might consider a Redis-based rate limiter (such as
|
234
|
+
Sidekiq::Limiter). Redis has better mechanisms for locking and updating keys,
|
235
|
+
it doesn't lose its working set on restart, and polling can be reduced or
|
236
|
+
eliminated through use of its built-in Lua scripting.
|
237
|
+
|
238
|
+
The limiting algorithm—which was overhauled for the 0.2.0 release to
|
239
|
+
greatly reduce the number of round-trips to Memcached—seems to work well,
|
240
|
+
but it is far from battle-tested. Simple benchmarking against a local Memcached
|
241
|
+
instance shows zero lock timeouts with the default settings and 200 threads
|
242
|
+
banging away at the same limit concurrently for an extended period of time.
|
243
|
+
(Testing performed on a 2012 MacBook Pro with an Intel i7-3615QM processor and
|
244
|
+
16 GB RAM; benchmarking scripts available in the `bin` subdirectory of this
|
245
|
+
repository.) I do plan on performing additional testing with a few more client
|
246
|
+
cores against a production (or production-like) Memcached ring at some point in
|
247
|
+
the near future and will update these results at that time.
|
205
248
|
|
206
249
|
As noted above, this is not a replacement for an application-level rate limit,
|
207
250
|
and if your application faces the web, you should probably definitely have
|
208
251
|
something else in your stack to handle e.g. a casual DoS.
|
209
252
|
|
210
|
-
Make sure your ConnectionPool has enough slots for these operations. I aim for
|
211
|
-
one slot per thread plus one or two for overhead in my applications.
|
212
|
-
|
213
253
|
## Documentation
|
214
254
|
|
215
255
|
This README is fairly comprehensive, but additional information about the
|
data/dalli-rate_limiter.gemspec
CHANGED
@@ -22,7 +22,6 @@ Gem::Specification.new do |spec|
|
|
22
22
|
spec.required_ruby_version = '>= 1.9.3'
|
23
23
|
|
24
24
|
spec.add_runtime_dependency "dalli", "~> 2.7.5"
|
25
|
-
spec.add_runtime_dependency "connection_pool", "~> 2.2.0"
|
26
25
|
|
27
26
|
spec.add_development_dependency "bundler", "~> 1.11.0"
|
28
27
|
spec.add_development_dependency "rake", "~> 10.5.0"
|
data/lib/dalli/rate_limiter.rb
CHANGED
@@ -1,8 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require "dalli"
|
4
|
-
require "connection_pool"
|
5
|
-
|
6
4
|
require "dalli/rate_limiter/version"
|
7
5
|
|
8
6
|
module Dalli
|
@@ -42,7 +40,7 @@ module Dalli
|
|
42
40
|
# @option options [Integer, Float] :lock_timeout (30) maximum number of
|
43
41
|
# seconds to wait for the lock to become available
|
44
42
|
def initialize(dalli = nil, options = {})
|
45
|
-
@pool = dalli ||
|
43
|
+
@pool = dalli || Dalli::Client.new
|
46
44
|
|
47
45
|
options = normalize_options options
|
48
46
|
|
@@ -72,11 +70,12 @@ module Dalli
|
|
72
70
|
#
|
73
71
|
# @raise [LockError] if a lock cannot be obtained before `@lock_timeout`
|
74
72
|
def exceeded?(unique_key = nil, to_consume = 1)
|
73
|
+
to_consume = to_consume.to_f
|
74
|
+
|
75
75
|
return false if to_consume <= 0
|
76
76
|
return -1 if to_consume > max_requests
|
77
77
|
|
78
78
|
key = [@key_prefix, unique_key].compact.join(":")
|
79
|
-
to_consume = to_consume.to_f
|
80
79
|
|
81
80
|
try = 1
|
82
81
|
total_time = 0
|
@@ -138,12 +137,12 @@ module Dalli
|
|
138
137
|
|
139
138
|
private
|
140
139
|
|
141
|
-
def compute(
|
140
|
+
def compute(previous_value, to_consume)
|
142
141
|
current_timestamp = Time.now.to_f
|
143
142
|
|
144
|
-
|
145
|
-
previous_allowance =
|
146
|
-
previous_timestamp =
|
143
|
+
previous_value ||= {}
|
144
|
+
previous_allowance = previous_value[:allowance] || @max_requests
|
145
|
+
previous_timestamp = previous_value[:timestamp] || current_timestamp
|
147
146
|
|
148
147
|
allowance_delta = (current_timestamp - previous_timestamp) * @max_requests / @period
|
149
148
|
projected_allowance = previous_allowance + allowance_delta
|
@@ -153,16 +152,16 @@ module Dalli
|
|
153
152
|
end
|
154
153
|
|
155
154
|
if to_consume > projected_allowance
|
156
|
-
# Determine how long the caller must wait (in seconds) before retrying
|
155
|
+
# Determine how long the caller must wait (in seconds) before retrying.
|
157
156
|
wait = (to_consume - projected_allowance) * @period / @max_requests
|
158
157
|
else
|
159
|
-
|
158
|
+
value = {
|
160
159
|
:allowance => previous_allowance + allowance_delta - to_consume,
|
161
160
|
:timestamp => current_timestamp
|
162
161
|
}
|
163
162
|
end
|
164
163
|
|
165
|
-
[wait || 0,
|
164
|
+
[wait || 0, value || previous_value]
|
166
165
|
end
|
167
166
|
|
168
167
|
def normalize_options(options)
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: dalli-rate_limiter
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Mike Pastore
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2016-02-
|
11
|
+
date: 2016-02-13 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: dalli
|
@@ -24,20 +24,6 @@ dependencies:
|
|
24
24
|
- - "~>"
|
25
25
|
- !ruby/object:Gem::Version
|
26
26
|
version: 2.7.5
|
27
|
-
- !ruby/object:Gem::Dependency
|
28
|
-
name: connection_pool
|
29
|
-
requirement: !ruby/object:Gem::Requirement
|
30
|
-
requirements:
|
31
|
-
- - "~>"
|
32
|
-
- !ruby/object:Gem::Version
|
33
|
-
version: 2.2.0
|
34
|
-
type: :runtime
|
35
|
-
prerelease: false
|
36
|
-
version_requirements: !ruby/object:Gem::Requirement
|
37
|
-
requirements:
|
38
|
-
- - "~>"
|
39
|
-
- !ruby/object:Gem::Version
|
40
|
-
version: 2.2.0
|
41
27
|
- !ruby/object:Gem::Dependency
|
42
28
|
name: bundler
|
43
29
|
requirement: !ruby/object:Gem::Requirement
|