dalli-rate_limiter 0.1.2 → 0.2.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 +7 -0
- data/.travis.yml +2 -0
- data/README.md +88 -36
- data/bin/bench.rb +31 -0
- data/bin/bench_block.rb +31 -0
- data/lib/dalli/rate_limiter.rb +114 -119
- data/lib/dalli/rate_limiter/version.rb +1 -1
- metadata +27 -44
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 1a4f84f2172c086af81637b00a5f9d29d6d856a2
|
4
|
+
data.tar.gz: f49d4308f8d54d941c4deb47a6c48995ab8f9a86
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 92c0c0915aaa215d4f7968113b95f09e710a9d5fbb1da2fc6c1466930ba3e0e8eba7ad2f3ec8eb6079597929eea22bb9c276d21270e6bcfc11fe246ef7191703
|
7
|
+
data.tar.gz: 2e12c617f7953b982f05d3e7072919234ca4c542268c66fbe54151931f725079263e22ac84fd28cab9ca6301a76e41581dd1a383551239fb85701d291b7f2657
|
data/.travis.yml
CHANGED
data/README.md
CHANGED
@@ -21,20 +21,26 @@ tightly integrate a check within your business logic.
|
|
21
21
|
[ConnectionPool][5] gems for fast and efficient Memcached access and
|
22
22
|
thread-safe connection pooling. It uses an allowance counter and floating
|
23
23
|
timestamp to implement a sliding window for each unique key, enforcing a limit
|
24
|
-
of _m_ requests over a period of _n_ seconds.
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
24
|
+
of _m_ requests over a period of _n_ seconds. If you're familiar with
|
25
|
+
[Sidekiq][10] (which is another excellent piece of software, written by the same
|
26
|
+
person who wrote Dalli and ConnectionPool), it is similar to the Window style
|
27
|
+
of the Sidekiq::Limiter class, although the invocation syntax differs slightly
|
28
|
+
(see [Block Form](#block-form) below for an example of the differences).
|
29
|
+
|
30
|
+
It supports arbitrary unit quantities of consumption for partial operations or
|
31
|
+
for operations that logically count as more than one request (i.e. batched
|
32
|
+
requests). It leverages Memcached's compare-and-set method—which uses an
|
33
|
+
opportunistic locking scheme—in combination with a back-off algorithm to
|
34
|
+
mitigate race conditions while ensuring that limits are enforced under high
|
35
|
+
levels of concurrency with a high degree of confidence. Math operations are
|
36
|
+
performed with floating-point precision.
|
31
37
|
|
32
38
|
## Installation
|
33
39
|
|
34
40
|
Add this line to your application's Gemfile:
|
35
41
|
|
36
42
|
```ruby
|
37
|
-
gem 'dalli-rate_limiter', '~> 0.
|
43
|
+
gem 'dalli-rate_limiter', '~> 0.2.0'
|
38
44
|
```
|
39
45
|
|
40
46
|
And then execute:
|
@@ -51,11 +57,11 @@ Or install it yourself as:
|
|
51
57
|
def do_foo
|
52
58
|
lim = Dalli::RateLimiter.new
|
53
59
|
|
54
|
-
if lim.exceeded?
|
60
|
+
if lim.exceeded?
|
55
61
|
fail "Sorry, can't foo right now. Try again later!"
|
56
62
|
end
|
57
63
|
|
58
|
-
#
|
64
|
+
# Do foo...
|
59
65
|
end
|
60
66
|
```
|
61
67
|
|
@@ -70,10 +76,9 @@ connection settings. Pass in `nil` to force the default behavior.
|
|
70
76
|
|
71
77
|
The library itself defaults to five (5) requests per eight (8) seconds, but
|
72
78
|
these can easily be changed with the `:max_requests` and `:period` options.
|
73
|
-
Locking can be
|
74
|
-
|
75
|
-
|
76
|
-
in the Dalli::Client.
|
79
|
+
Locking can be fine-tuned by setting the `:lock_timeout` option. A
|
80
|
+
`:key_prefix` option can be specified as well; note that this will be used in
|
81
|
+
combination with any `:namespace` option defined in the Dalli::Client.
|
77
82
|
|
78
83
|
The **Dalli::RateLimiter** instance itself is not stateful, so it can be
|
79
84
|
instantiated as needed (e.g. in a function definition) or in a more global
|
@@ -82,15 +87,15 @@ attributes so it should be safe to share between threads; in this case, you
|
|
82
87
|
will definitely want to use either the default ConnectionPool or your own (as
|
83
88
|
opposed to a single-threaded Dalli::Client instance).
|
84
89
|
|
85
|
-
The main instance method, `#exceeded?` will return false if the request is
|
86
|
-
to proceed. If the limit has been exceeded, it will return a positive
|
87
|
-
point value that represents the fractional number of seconds that the
|
88
|
-
should wait until retrying the request. Assuming no other requests were
|
89
|
-
during that time, the retried request will be free to proceed at that
|
90
|
-
When invoking this method, please be sure to pass in a key that is
|
91
|
-
combination with the `:key_prefix` option described above) to the
|
92
|
-
trying to limit. An optional second argument specifies the number
|
93
|
-
to "consume" from the allowance; this defaults to one (1).
|
90
|
+
The main instance method, `#exceeded?` will return `false` if the request is
|
91
|
+
free to proceed. If the limit has been exceeded, it will return a positive
|
92
|
+
floating point value that represents the fractional number of seconds that the
|
93
|
+
caller should wait until retrying the request. Assuming no other requests were
|
94
|
+
process during that time, the retried request will be free to proceed at that
|
95
|
+
point. When invoking this method, please be sure to pass in a key that is
|
96
|
+
unique (in combination with the `:key_prefix` option described above) to the
|
97
|
+
thing you are trying to limit. An optional second argument specifies the number
|
98
|
+
of requests to "consume" from the allowance; this defaults to one (1).
|
94
99
|
|
95
100
|
Please note that if the number of requests is greater than the maximum number
|
96
101
|
of requests, the limit will never not be exceeded. Consider a limit of 50
|
@@ -98,6 +103,14 @@ requests per minute: no amount of waiting would ever allow for a batch of 51
|
|
98
103
|
requests! `#exceeded?` returns `-1` in this event. To help detect this edge
|
99
104
|
case proactively, a public getter method `#max_requests` is available.
|
100
105
|
|
106
|
+
An alternative block-form syntax is available using the `#without_exceeding`
|
107
|
+
method. This method will call `sleep` on your behalf until the block can be
|
108
|
+
executed without exceeding the limit, and then yield to the block. This is
|
109
|
+
useful in situations where you want to avoid writing your own sleep-while loop.
|
110
|
+
You can limit how long the method will sleep by passing in a `:wait_timeout`
|
111
|
+
option; please note that the total wait time includes any time spent acquiring
|
112
|
+
the lock.
|
113
|
+
|
101
114
|
## Advanced Usage
|
102
115
|
|
103
116
|
```ruby
|
@@ -116,7 +129,9 @@ def change_username(user_id, new_username)
|
|
116
129
|
halt 422, "Sorry! Only two username changes allowed per hour."
|
117
130
|
end
|
118
131
|
|
119
|
-
#
|
132
|
+
# Change username...
|
133
|
+
rescue Dalli::RateLimiter::LockError
|
134
|
+
# Unable to acquire a lock...
|
120
135
|
end
|
121
136
|
|
122
137
|
def add_widgets(foo_id, some_widgets)
|
@@ -124,19 +139,50 @@ def add_widgets(foo_id, some_widgets)
|
|
124
139
|
halt 400, "Too many widgets!"
|
125
140
|
end
|
126
141
|
|
127
|
-
if time = lim2.exceeded?
|
142
|
+
if time = lim2.exceeded?(foo_id, some_widgets.length)
|
128
143
|
halt 422, "Sorry! Unable to process request. " \
|
129
144
|
"Please wait at least #{time} seconds before trying again."
|
130
145
|
end
|
131
146
|
|
132
|
-
#
|
147
|
+
# Add widgets...
|
148
|
+
rescue Dalli::RateLimiter::LockError
|
149
|
+
# Unable to acquire a lock...
|
133
150
|
end
|
134
151
|
```
|
135
152
|
|
153
|
+
## Block Form
|
154
|
+
|
155
|
+
Rewriting the Sidekiq::Limiter.window [example][9] from its documentation:
|
156
|
+
|
157
|
+
```ruby
|
158
|
+
def perform(user_id)
|
159
|
+
user_throttle = Dalli::RateLimiter.new nil,
|
160
|
+
:key_prefix => "stripe", :max_requests => 5, :period => 1
|
161
|
+
|
162
|
+
user_throttle.without_exceeding(user_id, 1, :wait_timeout => 5) do
|
163
|
+
# call stripe with user's account creds
|
164
|
+
end
|
165
|
+
rescue Dalli::RateLimiter::LimitError
|
166
|
+
# Unable to execute block before wait timeout...
|
167
|
+
rescue Dalli::RateLimiter::LockError
|
168
|
+
# Unable to acquire a lock...
|
169
|
+
end
|
170
|
+
```
|
171
|
+
|
172
|
+
You have the flexibility to set the `:key_prefix` to `nil` and pass in
|
173
|
+
`"stripe:#{user_id}"` as the first argument to `#without_exceeding`, with same
|
174
|
+
end results. Or, likewise, you could set `:key_prefix` to `"stripe:#{user_id}"`
|
175
|
+
and pass in `nil` as the first argument to `#without_exceeding`. Sometimes it
|
176
|
+
makes sense to share an instance between method calls, or indeed between
|
177
|
+
different methods, and sometimes it doesn't. Please note that if `:key_prefix`
|
178
|
+
and the first argument to `#exceeded?` or `#without_exceeding` are both `nil`,
|
179
|
+
Dalli::Client will abort with an ArgumentError ("key cannot be blank").
|
180
|
+
|
136
181
|
## Compatibility
|
137
182
|
|
138
183
|
**Dalli::RateLimiter** is compatible with Ruby 1.9.3 and greater and has been
|
139
|
-
tested with frozen string literals under Ruby 2.3.0.
|
184
|
+
tested with frozen string literals under Ruby 2.3.0. It has also been tested
|
185
|
+
under Rubinius 2.15 and 3.14, and JRuby 1.7 (in 1.9.3 execution mode) and 9K.
|
140
186
|
|
141
187
|
You might consider installing the [kgio][7] gem to [give Dalli a 10-20%
|
142
188
|
performance boost][8].
|
@@ -149,15 +195,13 @@ noted that a Memcached ring can lose members or indeed its entire working set
|
|
149
195
|
cases, where repeated operations absolutely, positively have to be restricted,
|
150
196
|
should probably seek solutions elsewhere.
|
151
197
|
|
152
|
-
The limiting algorithm
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
I will likely be revisiting the algorithm in the future, but at the moment it
|
160
|
-
is in the unfortunate state of "good enough".
|
198
|
+
The limiting algorithm, which was overhauled for the 0.2.0 release to greatly
|
199
|
+
reduce the number of round-trips to Memcached, seems to work well but it is
|
200
|
+
far from battle-tested. Simple benchmarking against a local Memcached instance
|
201
|
+
shows zero lock timeouts with the default settings and 100 threads hitting the
|
202
|
+
same limit concurrently. (Testing performed on a 2012 MacBook Pro with an Intel
|
203
|
+
i7-3615QM processor and 16 GB RAM; benchmarking scripts available in the `bin`
|
204
|
+
subdirectory of this repository.)
|
161
205
|
|
162
206
|
As noted above, this is not a replacement for an application-level rate limit,
|
163
207
|
and if your application faces the web, you should probably definitely have
|
@@ -166,6 +210,11 @@ something else in your stack to handle e.g. a casual DoS.
|
|
166
210
|
Make sure your ConnectionPool has enough slots for these operations. I aim for
|
167
211
|
one slot per thread plus one or two for overhead in my applications.
|
168
212
|
|
213
|
+
## Documentation
|
214
|
+
|
215
|
+
This README is fairly comprehensive, but additional information about the
|
216
|
+
class and its methods is available in [YARD][11].
|
217
|
+
|
169
218
|
## Development
|
170
219
|
|
171
220
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run
|
@@ -196,3 +245,6 @@ License](http://opensource.org/licenses/MIT).
|
|
196
245
|
[6]: http://memcached.org "Memcached"
|
197
246
|
[7]: http://bogomips.org/kgio "kgio"
|
198
247
|
[8]: https://github.com/petergoldstein/dalli/blob/master/Performance.md "Dalli Performance"
|
248
|
+
[9]: https://github.com/mperham/sidekiq/wiki/Ent-Rate-Limiting#window "Sidekiq::Limiter.window"
|
249
|
+
[10]: http://sidekiq.org "Sidekiq"
|
250
|
+
[11]: http://www.rubydoc.info/github/mwpastore/dalli-rate_limiter/master/Dalli/RateLimiter "Dalli::RateLimiter on RubyDoc.info"
|
data/bin/bench.rb
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "dalli/rate_limiter"
|
5
|
+
|
6
|
+
require "thread"
|
7
|
+
require "thwait"
|
8
|
+
|
9
|
+
NUM_THREADS = 100
|
10
|
+
|
11
|
+
lim = Dalli::RateLimiter.new nil,
|
12
|
+
:key_prefix => "bench", :max_requests => 100_000, :period => 1
|
13
|
+
|
14
|
+
mutex = Mutex.new
|
15
|
+
error_count = 0
|
16
|
+
|
17
|
+
threads = NUM_THREADS.times.map do
|
18
|
+
Thread.new do
|
19
|
+
1_000.times do
|
20
|
+
begin
|
21
|
+
lim.exceeded?
|
22
|
+
rescue
|
23
|
+
mutex.synchronize { error_count += 1 }
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
ThreadsWait.all_waits(*threads)
|
30
|
+
|
31
|
+
puts "errors: #{error_count}"
|
data/bin/bench_block.rb
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "dalli/rate_limiter"
|
5
|
+
|
6
|
+
require "thread"
|
7
|
+
require "thwait"
|
8
|
+
|
9
|
+
NUM_THREADS = 100
|
10
|
+
|
11
|
+
lim = Dalli::RateLimiter.new nil,
|
12
|
+
:key_prefix => "bench_block", :max_requests => 100_000, :period => 1
|
13
|
+
|
14
|
+
mutex = Mutex.new
|
15
|
+
error_count = 0
|
16
|
+
|
17
|
+
threads = NUM_THREADS.times.map do
|
18
|
+
Thread.new do
|
19
|
+
1_000.times do
|
20
|
+
begin
|
21
|
+
lim.without_exceeding { nil }
|
22
|
+
rescue
|
23
|
+
mutex.synchronize { error_count += 1 }
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
ThreadsWait.all_waits(*threads)
|
30
|
+
|
31
|
+
puts "errors: #{error_count}"
|
data/lib/dalli/rate_limiter.rb
CHANGED
@@ -6,186 +6,181 @@ require "connection_pool"
|
|
6
6
|
require "dalli/rate_limiter/version"
|
7
7
|
|
8
8
|
module Dalli
|
9
|
-
INVALID_KEY_CHARS = [
|
10
|
-
0x00, 0x20,
|
11
|
-
0x09, 0x0a,
|
12
|
-
0x0d
|
13
|
-
].map(&:chr).join("").freeze
|
14
|
-
|
15
9
|
# Dalli::RateLimiter provides arbitrary Memcached-backed rate limiting for
|
16
10
|
# your Ruby applications.
|
17
11
|
#
|
18
12
|
# @see file:README.md
|
19
13
|
#
|
20
14
|
# @!attribute [r] max_requests
|
21
|
-
# @return [Float] the maximum number of requests
|
15
|
+
# @return [Float] the maximum number of requests
|
22
16
|
class RateLimiter
|
23
|
-
|
24
|
-
|
17
|
+
LockError = Class.new RuntimeError
|
18
|
+
LimitError = Class.new RuntimeError
|
25
19
|
|
26
20
|
DEFAULT_OPTIONS = {
|
27
21
|
:key_prefix => "dalli-rate_limiter",
|
28
|
-
:max_requests =>
|
29
|
-
:period =>
|
30
|
-
:
|
22
|
+
:max_requests => 5,
|
23
|
+
:period => 8,
|
24
|
+
:lock_timeout => 30
|
31
25
|
}.freeze
|
32
26
|
|
33
|
-
|
27
|
+
attr_reader :max_requests
|
34
28
|
|
35
29
|
# Create a new instance of Dalli::RateLimiter.
|
36
30
|
#
|
37
|
-
# @param dalli [
|
31
|
+
# @param dalli [ConnectionPool, Dalli::Client] the Dalli::Client (or
|
38
32
|
# ConnectionPool of Dalli::Client) to use as a backing store for this
|
39
33
|
# rate limiter
|
40
|
-
# @param options [Hash
|
34
|
+
# @param options [Hash] configuration options for this rate limiter
|
41
35
|
#
|
42
|
-
# @option options [String] :key_prefix ("dalli-rate_limiter") a
|
43
|
-
# string describing this rate limiter
|
36
|
+
# @option options [String, #to_s] :key_prefix ("dalli-rate_limiter") a
|
37
|
+
# unique string describing this rate limiter
|
44
38
|
# @option options [Integer, Float] :max_requests (5) maximum number of
|
45
39
|
# requests over the governed interval
|
46
40
|
# @option options [Integer, Float] :period (8) number of seconds over
|
47
41
|
# which to enforce the maximum number of requests
|
48
|
-
# @option options [
|
42
|
+
# @option options [Integer, Float] :lock_timeout (30) maximum number of
|
43
|
+
# seconds to wait for the lock to become available
|
49
44
|
def initialize(dalli = nil, options = {})
|
50
|
-
@
|
45
|
+
@pool = dalli || ConnectionPool.new { Dalli::Client.new }
|
51
46
|
|
52
47
|
options = normalize_options options
|
53
48
|
|
54
49
|
@key_prefix = options[:key_prefix]
|
55
50
|
@max_requests = options[:max_requests]
|
56
51
|
@period = options[:period]
|
57
|
-
@
|
58
|
-
end
|
59
|
-
|
60
|
-
def max_requests
|
61
|
-
to_fs @max_requests
|
52
|
+
@lock_timeout = options[:lock_timeout]
|
62
53
|
end
|
63
54
|
|
64
55
|
# Determine whether processing a given request would exceed the rate limit.
|
65
56
|
#
|
66
|
-
# @param unique_key [String] a key to use, in combination with the
|
67
|
-
# `:key_prefix` and any `:namespace` defined in the
|
68
|
-
# distinguish the item being limited from similar items
|
57
|
+
# @param unique_key [String, #to_s] a key to use, in combination with the
|
58
|
+
# optional `:key_prefix` and any `:namespace` defined in the
|
59
|
+
# Dalli::Client, to distinguish the item being limited from similar items
|
69
60
|
# @param to_consume [Integer, Float] the number of requests to consume from
|
70
|
-
# the allowance (used to represent a batch of
|
61
|
+
# the allowance (used to represent a partial request or a batch of
|
62
|
+
# requests)
|
71
63
|
#
|
72
64
|
# @return [false] if the request can be processed as given without
|
73
65
|
# exceeding the limit (including the case where the number to consume is
|
74
66
|
# zero)
|
75
67
|
# @return [Float] if processing the request as given would exceed
|
76
|
-
# the limit and the caller should wait so many
|
68
|
+
# the limit and the caller should wait so many (fractional) seconds
|
77
69
|
# before retrying
|
78
|
-
# @return [-1] if the number to consume exceeds the maximum,
|
79
|
-
#
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
to_consume
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
dc.
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
allowance_delta = (1.0 * (current_timestamp - previous_timestamp) * @max_requests / @period).to_i
|
107
|
-
projected_allowance = previous_allowance + allowance_delta
|
108
|
-
if projected_allowance > @max_requests
|
109
|
-
projected_allowance = @max_requests
|
110
|
-
allowance_delta = @max_requests - previous_allowance
|
111
|
-
end
|
112
|
-
|
113
|
-
if to_consume > projected_allowance
|
114
|
-
release_lock(dc, unique_key) if lock
|
115
|
-
|
116
|
-
# Tell the caller how long (in seconds) to wait before retrying the request.
|
117
|
-
return to_fs((1.0 * (to_consume - projected_allowance) * @period / @max_requests).to_i)
|
70
|
+
# @return [-1] if the number to consume exceeds the maximum, and the
|
71
|
+
# request as given would never not exceed the limit
|
72
|
+
#
|
73
|
+
# @raise [LockError] if a lock cannot be obtained before `@lock_timeout`
|
74
|
+
def exceeded?(unique_key = nil, to_consume = 1)
|
75
|
+
return false if to_consume <= 0
|
76
|
+
return -1 if to_consume > max_requests
|
77
|
+
|
78
|
+
key = [@key_prefix, unique_key].compact.join(":")
|
79
|
+
to_consume = to_consume.to_f
|
80
|
+
|
81
|
+
try = 1
|
82
|
+
total_time = 0
|
83
|
+
loop do
|
84
|
+
@pool.with do |dc|
|
85
|
+
result = dc.cas(key, @period) do |previous_value|
|
86
|
+
wait, value = compute(previous_value, to_consume)
|
87
|
+
return wait if wait > 0 # caller must wait
|
88
|
+
value
|
89
|
+
end
|
90
|
+
|
91
|
+
# TODO: We can get rid of this block when Dalli::Client supports #cas!
|
92
|
+
if result.nil?
|
93
|
+
_, value = compute(nil, to_consume)
|
94
|
+
result = dc.add(key, value, @period)
|
95
|
+
end
|
96
|
+
|
97
|
+
return false if result # caller can proceed
|
118
98
|
end
|
119
99
|
|
120
|
-
|
100
|
+
time = rand * Math.sqrt(try / Math::E)
|
101
|
+
raise LockError, "Unable to lock key for update" \
|
102
|
+
if time + total_time > @lock_timeout
|
103
|
+
sleep time
|
121
104
|
|
122
|
-
|
123
|
-
|
124
|
-
dc.send(allowance_delta < 0 ? :decr : :incr, allowance_key, allowance_delta.abs)
|
125
|
-
dc.touch(allowance_key, @period)
|
126
|
-
|
127
|
-
release_lock(dc, unique_key) if lock
|
128
|
-
|
129
|
-
return false
|
105
|
+
try += 1
|
106
|
+
total_time += time
|
130
107
|
end
|
131
108
|
end
|
132
109
|
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
110
|
+
# Execute a block without exceeding the rate limit.
|
111
|
+
#
|
112
|
+
# @param (see #exceeded?)
|
113
|
+
# @param options [Hash] configuration options
|
114
|
+
#
|
115
|
+
# @option options [Integer] :wait_timeout maximum number of seconds to wait
|
116
|
+
# before yielding
|
117
|
+
#
|
118
|
+
# @yield block to execute within limit
|
119
|
+
#
|
120
|
+
# @raise [LimitError] if the block cannot be yielded to within
|
121
|
+
# `:wait_timeout` seconds without going over the limit
|
122
|
+
# @raise (see #exceeded?)
|
123
|
+
#
|
124
|
+
# @return the return value of the passed block
|
125
|
+
def without_exceeding(unique_key = nil, to_consume = 1, options = {})
|
126
|
+
options[:wait_timeout] = options[:wait_timeout].to_f \
|
127
|
+
if options[:wait_timeout]
|
128
|
+
|
129
|
+
start_time = Time.now.to_f
|
130
|
+
while time = exceeded?(unique_key, to_consume)
|
131
|
+
raise LimitError, "Unable to yield without exceeding limit" \
|
132
|
+
if time < 0 || options[:wait_timeout] && time + Time.now.to_f - start_time > options[:wait_timeout]
|
133
|
+
sleep time
|
134
|
+
end
|
137
135
|
|
138
|
-
|
139
|
-
|
136
|
+
yield
|
137
|
+
end
|
140
138
|
|
141
|
-
|
142
|
-
if options[:max_requests]
|
139
|
+
private
|
143
140
|
|
144
|
-
|
145
|
-
|
141
|
+
def compute(previous, to_consume)
|
142
|
+
current_timestamp = Time.now.to_f
|
146
143
|
|
147
|
-
|
148
|
-
|
144
|
+
previous ||= {}
|
145
|
+
previous_allowance = previous[:allowance] || @max_requests
|
146
|
+
previous_timestamp = previous[:timestamp] || current_timestamp
|
149
147
|
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
148
|
+
allowance_delta = (current_timestamp - previous_timestamp) * @max_requests / @period
|
149
|
+
projected_allowance = previous_allowance + allowance_delta
|
150
|
+
if projected_allowance > @max_requests
|
151
|
+
projected_allowance = @max_requests
|
152
|
+
allowance_delta = @max_requests - previous_allowance
|
153
|
+
end
|
155
154
|
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
155
|
+
if to_consume > projected_allowance
|
156
|
+
# Determine how long the caller must wait (in seconds) before retrying the request.
|
157
|
+
wait = (to_consume - projected_allowance) * @period / @max_requests
|
158
|
+
else
|
159
|
+
current = {
|
160
|
+
:allowance => previous_allowance + allowance_delta - to_consume,
|
161
|
+
:timestamp => current_timestamp
|
162
|
+
}
|
162
163
|
end
|
163
164
|
|
164
|
-
|
165
|
+
[wait || 0, current || previous]
|
165
166
|
end
|
166
167
|
|
167
|
-
def
|
168
|
-
|
168
|
+
def normalize_options(options)
|
169
|
+
normalized_options = {}
|
169
170
|
|
170
|
-
|
171
|
-
|
171
|
+
normalized_options[:key_prefix] = options[:key_prefix].to_s \
|
172
|
+
if options.key? :key_prefix
|
172
173
|
|
173
|
-
|
174
|
-
|
175
|
-
(fs * 1_000).to_i
|
176
|
-
end
|
174
|
+
normalized_options[:max_requests] = options[:max_requests].to_f \
|
175
|
+
if options[:max_requests] && options[:max_requests].to_f > 0
|
177
176
|
|
178
|
-
|
179
|
-
|
180
|
-
1.0 * ems / 1_000
|
181
|
-
end
|
177
|
+
normalized_options[:period] = options[:period].to_f \
|
178
|
+
if options[:period] && options[:period].to_f > 0
|
182
179
|
|
183
|
-
|
184
|
-
|
185
|
-
end
|
180
|
+
normalized_options[:lock_timeout] = options[:lock_timeout].to_f \
|
181
|
+
if options[:lock_timeout] && options[:lock_timeout].to_f >= 0
|
186
182
|
|
187
|
-
|
188
|
-
key.to_s.delete INVALID_KEY_CHARS
|
183
|
+
DEFAULT_OPTIONS.dup.merge! normalized_options
|
189
184
|
end
|
190
185
|
end
|
191
186
|
end
|
metadata
CHANGED
@@ -1,126 +1,111 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: dalli-rate_limiter
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
5
|
-
prerelease:
|
4
|
+
version: 0.2.0
|
6
5
|
platform: ruby
|
7
6
|
authors:
|
8
7
|
- Mike Pastore
|
9
8
|
autorequire:
|
10
9
|
bindir: exe
|
11
10
|
cert_chain: []
|
12
|
-
date: 2016-02-
|
11
|
+
date: 2016-02-08 00:00:00.000000000 Z
|
13
12
|
dependencies:
|
14
13
|
- !ruby/object:Gem::Dependency
|
15
14
|
name: dalli
|
16
15
|
requirement: !ruby/object:Gem::Requirement
|
17
|
-
none: false
|
18
16
|
requirements:
|
19
|
-
- - ~>
|
17
|
+
- - "~>"
|
20
18
|
- !ruby/object:Gem::Version
|
21
19
|
version: 2.7.5
|
22
20
|
type: :runtime
|
23
21
|
prerelease: false
|
24
22
|
version_requirements: !ruby/object:Gem::Requirement
|
25
|
-
none: false
|
26
23
|
requirements:
|
27
|
-
- - ~>
|
24
|
+
- - "~>"
|
28
25
|
- !ruby/object:Gem::Version
|
29
26
|
version: 2.7.5
|
30
27
|
- !ruby/object:Gem::Dependency
|
31
28
|
name: connection_pool
|
32
29
|
requirement: !ruby/object:Gem::Requirement
|
33
|
-
none: false
|
34
30
|
requirements:
|
35
|
-
- - ~>
|
31
|
+
- - "~>"
|
36
32
|
- !ruby/object:Gem::Version
|
37
33
|
version: 2.2.0
|
38
34
|
type: :runtime
|
39
35
|
prerelease: false
|
40
36
|
version_requirements: !ruby/object:Gem::Requirement
|
41
|
-
none: false
|
42
37
|
requirements:
|
43
|
-
- - ~>
|
38
|
+
- - "~>"
|
44
39
|
- !ruby/object:Gem::Version
|
45
40
|
version: 2.2.0
|
46
41
|
- !ruby/object:Gem::Dependency
|
47
42
|
name: bundler
|
48
43
|
requirement: !ruby/object:Gem::Requirement
|
49
|
-
none: false
|
50
44
|
requirements:
|
51
|
-
- - ~>
|
45
|
+
- - "~>"
|
52
46
|
- !ruby/object:Gem::Version
|
53
47
|
version: 1.11.0
|
54
48
|
type: :development
|
55
49
|
prerelease: false
|
56
50
|
version_requirements: !ruby/object:Gem::Requirement
|
57
|
-
none: false
|
58
51
|
requirements:
|
59
|
-
- - ~>
|
52
|
+
- - "~>"
|
60
53
|
- !ruby/object:Gem::Version
|
61
54
|
version: 1.11.0
|
62
55
|
- !ruby/object:Gem::Dependency
|
63
56
|
name: rake
|
64
57
|
requirement: !ruby/object:Gem::Requirement
|
65
|
-
none: false
|
66
58
|
requirements:
|
67
|
-
- - ~>
|
59
|
+
- - "~>"
|
68
60
|
- !ruby/object:Gem::Version
|
69
61
|
version: 10.5.0
|
70
62
|
type: :development
|
71
63
|
prerelease: false
|
72
64
|
version_requirements: !ruby/object:Gem::Requirement
|
73
|
-
none: false
|
74
65
|
requirements:
|
75
|
-
- - ~>
|
66
|
+
- - "~>"
|
76
67
|
- !ruby/object:Gem::Version
|
77
68
|
version: 10.5.0
|
78
69
|
- !ruby/object:Gem::Dependency
|
79
70
|
name: rubocop
|
80
71
|
requirement: !ruby/object:Gem::Requirement
|
81
|
-
none: false
|
82
72
|
requirements:
|
83
|
-
- - ~>
|
73
|
+
- - "~>"
|
84
74
|
- !ruby/object:Gem::Version
|
85
75
|
version: 0.35.0
|
86
76
|
type: :development
|
87
77
|
prerelease: false
|
88
78
|
version_requirements: !ruby/object:Gem::Requirement
|
89
|
-
none: false
|
90
79
|
requirements:
|
91
|
-
- - ~>
|
80
|
+
- - "~>"
|
92
81
|
- !ruby/object:Gem::Version
|
93
82
|
version: 0.35.0
|
94
83
|
- !ruby/object:Gem::Dependency
|
95
84
|
name: rspec
|
96
85
|
requirement: !ruby/object:Gem::Requirement
|
97
|
-
none: false
|
98
86
|
requirements:
|
99
|
-
- - ~>
|
87
|
+
- - "~>"
|
100
88
|
- !ruby/object:Gem::Version
|
101
89
|
version: 3.4.0
|
102
90
|
type: :development
|
103
91
|
prerelease: false
|
104
92
|
version_requirements: !ruby/object:Gem::Requirement
|
105
|
-
none: false
|
106
93
|
requirements:
|
107
|
-
- - ~>
|
94
|
+
- - "~>"
|
108
95
|
- !ruby/object:Gem::Version
|
109
96
|
version: 3.4.0
|
110
97
|
- !ruby/object:Gem::Dependency
|
111
98
|
name: rspec-given
|
112
99
|
requirement: !ruby/object:Gem::Requirement
|
113
|
-
none: false
|
114
100
|
requirements:
|
115
|
-
- - ~>
|
101
|
+
- - "~>"
|
116
102
|
- !ruby/object:Gem::Version
|
117
103
|
version: 3.8.0
|
118
104
|
type: :development
|
119
105
|
prerelease: false
|
120
106
|
version_requirements: !ruby/object:Gem::Requirement
|
121
|
-
none: false
|
122
107
|
requirements:
|
123
|
-
- - ~>
|
108
|
+
- - "~>"
|
124
109
|
- !ruby/object:Gem::Version
|
125
110
|
version: 3.8.0
|
126
111
|
description: Arbitrary Memcached-backed rate limiting for Ruby
|
@@ -130,14 +115,16 @@ executables: []
|
|
130
115
|
extensions: []
|
131
116
|
extra_rdoc_files: []
|
132
117
|
files:
|
133
|
-
- .gitignore
|
134
|
-
- .rspec
|
135
|
-
- .rubocop.yml
|
136
|
-
- .travis.yml
|
118
|
+
- ".gitignore"
|
119
|
+
- ".rspec"
|
120
|
+
- ".rubocop.yml"
|
121
|
+
- ".travis.yml"
|
137
122
|
- Gemfile
|
138
123
|
- LICENSE.txt
|
139
124
|
- README.md
|
140
125
|
- Rakefile
|
126
|
+
- bin/bench.rb
|
127
|
+
- bin/bench_block.rb
|
141
128
|
- bin/console
|
142
129
|
- bin/setup
|
143
130
|
- dalli-rate_limiter.gemspec
|
@@ -147,29 +134,25 @@ files:
|
|
147
134
|
homepage: https://github.com/mwpastore/dalli-rate_limiter
|
148
135
|
licenses:
|
149
136
|
- MIT
|
137
|
+
metadata: {}
|
150
138
|
post_install_message:
|
151
139
|
rdoc_options: []
|
152
140
|
require_paths:
|
153
141
|
- lib
|
154
142
|
required_ruby_version: !ruby/object:Gem::Requirement
|
155
|
-
none: false
|
156
143
|
requirements:
|
157
|
-
- -
|
144
|
+
- - ">="
|
158
145
|
- !ruby/object:Gem::Version
|
159
146
|
version: 1.9.3
|
160
147
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
161
|
-
none: false
|
162
148
|
requirements:
|
163
|
-
- -
|
149
|
+
- - ">="
|
164
150
|
- !ruby/object:Gem::Version
|
165
151
|
version: '0'
|
166
|
-
segments:
|
167
|
-
- 0
|
168
|
-
hash: 2846745512546726026
|
169
152
|
requirements: []
|
170
153
|
rubyforge_project:
|
171
|
-
rubygems_version:
|
154
|
+
rubygems_version: 2.4.5.1
|
172
155
|
signing_key:
|
173
|
-
specification_version:
|
156
|
+
specification_version: 4
|
174
157
|
summary: Arbitrary Memcached-backed rate limiting for Ruby
|
175
158
|
test_files: []
|