dalli-rate_limiter 0.1.2 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -11,4 +11,6 @@ rvm:
11
11
  - jruby-9.0.5.0
12
12
  - rbx-2.5.8
13
13
  - rbx-3.14
14
+ jdk:
15
+ - oraclejdk8
14
16
  before_install: gem install bundler -v 1.11.2
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. It supports arbitrary unit
25
- quantities of consumption for operations that logically count as more than one
26
- request (i.e. batched requests). A simple mutex locking scheme (enabled by
27
- default) is used to mitigate race conditions and ensure that the limit is
28
- enforced under most cirumstances (see [Caveats](#caveats) below). Math
29
- operations are performed with three decimal places of precision but the results
30
- are stored in Memcached as integers.
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.1.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? "foo"
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 disabled by setting the `:locking` option to `false` (see
74
- [Caveats](#caveats) below). A `:key_prefix` option can be specified as well;
75
- note that this will be used in combination with any `:namespace` option defined
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 free
86
- to proceed. If the limit has been exceeded, it will return a positive floating
87
- point value that represents the fractional number of seconds that the caller
88
- should wait until retrying the request. Assuming no other requests were process
89
- during that time, the retried request will be free to proceed at that point.
90
- When invoking this method, please be sure to pass in a key that is unique (in
91
- combination with the `:key_prefix` option described above) to the thing you are
92
- trying to limit. An optional second argument specifies the number of requests
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? foo_id, some_widgets.length
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 seems to work well but it is far from battle-tested. I
153
- tried to use atomic operations where possible to mitigate race conditions, but
154
- still had to implement a locking scheme, which might slow down operations and
155
- lead to timeouts and exceptions if a lock can't be acquired for some reason.
156
- Locking can be disabled but this will increase the chances that a determined
157
- attacker figures out a way to defeat the limit.
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}"
@@ -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}"
@@ -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 in a human-friendly format
15
+ # @return [Float] the maximum number of requests
22
16
  class RateLimiter
23
- LOCK_TTL = 30
24
- LOCK_MAX_TRIES = 6
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 => 5_000,
29
- :period => 8_000,
30
- :locking => true
22
+ :max_requests => 5,
23
+ :period => 8,
24
+ :lock_timeout => 30
31
25
  }.freeze
32
26
 
33
- private_constant :DEFAULT_OPTIONS
27
+ attr_reader :max_requests
34
28
 
35
29
  # Create a new instance of Dalli::RateLimiter.
36
30
  #
37
- # @param dalli [nil, ConnectionPool, Dalli::Client] the Dalli::Client (or
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{Symbol}] configuration options for this rate limiter
34
+ # @param options [Hash] configuration options for this rate limiter
41
35
  #
42
- # @option options [String] :key_prefix ("dalli-rate_limiter") a unique
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 [Boolean] :locking (true) enable or disable locking
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
- @dalli = dalli || ConnectionPool.new { Dalli::Client.new }
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
- @locking = options[:locking]
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 Dalli::Client, to
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 requests)
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 [fractional] seconds
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
- # and the request as given would never not exceed the limit
80
- def exceeded?(unique_key, to_consume = 1)
81
- return false if to_consume == 0
82
-
83
- to_consume = to_ems(to_consume)
84
-
85
- return -1 if to_consume > @max_requests
86
-
87
- timestamp_key = format_key(unique_key, "timestamp")
88
- allowance_key = format_key(unique_key, "allowance")
89
-
90
- @dalli.with do |dc|
91
- if dc.add(allowance_key, @max_requests - to_consume, @period, :raw => true)
92
- # Short-circuit the simple case of seeing the key for the first time.
93
- dc.set(timestamp_key, to_ems(Time.now.to_f), @period, :raw => true)
94
-
95
- return false
96
- end
97
-
98
- lock = acquire_lock(dc, unique_key) if @locking
99
-
100
- current_timestamp = to_ems(Time.now.to_f) # obtain timestamp after locking
101
-
102
- previous = dc.get_multi allowance_key, timestamp_key
103
- previous_allowance = previous.key?(allowance_key) ? previous[allowance_key].to_i : @max_requests
104
- previous_timestamp = previous.key?(timestamp_key) ? previous[timestamp_key].to_i : current_timestamp
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
- allowance_delta -= to_consume
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
- dc.set(timestamp_key, current_timestamp, @period, :raw => true)
123
- dc.add(allowance_key, previous_allowance, 0, :raw => true) # ensure baseline exists
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
- private
134
-
135
- def normalize_options(options)
136
- normalized_options = {}
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
- normalized_options[:key_prefix] = cleanse_key options[:key_prefix] \
139
- if options[:key_prefix]
136
+ yield
137
+ end
140
138
 
141
- normalized_options[:max_requests] = to_ems options[:max_requests].to_f \
142
- if options[:max_requests]
139
+ private
143
140
 
144
- normalized_options[:period] = to_ems options[:period].to_f \
145
- if options[:period]
141
+ def compute(previous, to_consume)
142
+ current_timestamp = Time.now.to_f
146
143
 
147
- normalized_options[:locking] = !!options[:locking] \
148
- if options.key? :locking
144
+ previous ||= {}
145
+ previous_allowance = previous[:allowance] || @max_requests
146
+ previous_timestamp = previous[:timestamp] || current_timestamp
149
147
 
150
- DEFAULT_OPTIONS.dup.merge! normalized_options
151
- end
152
-
153
- def acquire_lock(dc, key)
154
- lock_key = format_key(key, "mutex")
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
- (1..LOCK_MAX_TRIES).each do |tries|
157
- if lock = dc.add(lock_key, true, LOCK_TTL)
158
- return lock
159
- else
160
- sleep rand(2**tries)
161
- end
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
- raise DalliError, "Unable to lock key for update"
165
+ [wait || 0, current || previous]
165
166
  end
166
167
 
167
- def release_lock(dc, key)
168
- lock_key = format_key(key, "mutex")
168
+ def normalize_options(options)
169
+ normalized_options = {}
169
170
 
170
- dc.delete(lock_key)
171
- end
171
+ normalized_options[:key_prefix] = options[:key_prefix].to_s \
172
+ if options.key? :key_prefix
172
173
 
173
- # Convert fractional units to encoded milliunits
174
- def to_ems(fs)
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
- # Convert encoded milliunits to fractional units
179
- def to_fs(ems)
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
- def format_key(key, attribute)
184
- "#{@key_prefix}:#{cleanse_key key}:#{attribute}"
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
- def cleanse_key(key)
188
- key.to_s.delete INVALID_KEY_CHARS
183
+ DEFAULT_OPTIONS.dup.merge! normalized_options
189
184
  end
190
185
  end
191
186
  end
@@ -1,5 +1,5 @@
1
1
  module Dalli
2
2
  class RateLimiter
3
- VERSION = "0.1.2"
3
+ VERSION = "0.2.0"
4
4
  end
5
5
  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.1.2
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-01 00:00:00.000000000 Z
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: 1.8.23.2
154
+ rubygems_version: 2.4.5.1
172
155
  signing_key:
173
- specification_version: 3
156
+ specification_version: 4
174
157
  summary: Arbitrary Memcached-backed rate limiting for Ruby
175
158
  test_files: []