dalli-rate_limiter 0.2.0 → 0.3.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 +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
|