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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 1a4f84f2172c086af81637b00a5f9d29d6d856a2
4
- data.tar.gz: f49d4308f8d54d941c4deb47a6c48995ab8f9a86
3
+ metadata.gz: 192caf393892d4ebed3b31ea0a9723eb7c6b5f65
4
+ data.tar.gz: 7a968b2e507d1b264de374e05089a49a08a9bd46
5
5
  SHA512:
6
- metadata.gz: 92c0c0915aaa215d4f7968113b95f09e710a9d5fbb1da2fc6c1466930ba3e0e8eba7ad2f3ec8eb6079597929eea22bb9c276d21270e6bcfc11fe246ef7191703
7
- data.tar.gz: 2e12c617f7953b982f05d3e7072919234ca4c542268c66fbe54151931f725079263e22ac84fd28cab9ca6301a76e41581dd1a383551239fb85701d291b7f2657
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 Nginx zone or HAproxy stick-table).
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
- [ConnectionPool][5] gems for fast and efficient Memcached access and
22
- thread-safe connection pooling. It uses an allowance counter and floating
23
- timestamp to implement a sliding window for each unique key, enforcing a limit
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).
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.2.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, create a ConnectionPool with its
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
- (wrapping Dalli::Client)—as the first argument to customize the
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, so it can be
84
- instantiated as needed (e.g. in a function definition) or in a more global
85
- scope (e.g. in a Rails initializer). It does not mutate any of its own
86
- attributes so it should be safe to share between threads; in this case, you
87
- will definitely want to use either the default ConnectionPool or your own (as
88
- opposed to a single-threaded Dalli::Client instance).
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. When invoking this method, please be sure to pass in a key that is
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(nil, :namespace => "myapp")
126
+ Dalli::Client.new nil, :namespace => "myapp"
119
127
  }
120
128
 
121
- lim1 = Dalli::RateLimiter.new dalli,
122
- :key_prefix => "username-throttle", :max_requests => 2, :period => 3_600
129
+ USERNAME_LIMIT = Dalli::RateLimiter.new dalli,
130
+ :key_prefix => "username-limit", :max_requests => 2, :period => 3_600
123
131
 
124
- lim2 = Dalli::RateLimiter.new dalli,
125
- :key_prefix => "widgets-throttle", :max_requests => 10, :period => 60
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 lim1.exceeded? user_id
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(foo_id, some_widgets)
138
- if some_widgets.length > lim2.max_requests
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 = lim2.exceeded?(foo_id, some_widgets.length)
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
- Rewriting the Sidekiq::Limiter.window [example][9] from its documentation:
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 `#exceeded?` or `#without_exceeding` are both `nil`,
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
- You might consider installing the [kgio][7] gem to [give Dalli a 10-20%
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
- 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.)
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
@@ -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"
@@ -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 || ConnectionPool.new { Dalli::Client.new }
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(previous, to_consume)
140
+ def compute(previous_value, to_consume)
142
141
  current_timestamp = Time.now.to_f
143
142
 
144
- previous ||= {}
145
- previous_allowance = previous[:allowance] || @max_requests
146
- previous_timestamp = previous[:timestamp] || current_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 the request.
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
- current = {
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, current || previous]
164
+ [wait || 0, value || previous_value]
166
165
  end
167
166
 
168
167
  def normalize_options(options)
@@ -1,5 +1,5 @@
1
1
  module Dalli
2
2
  class RateLimiter
3
- VERSION = "0.2.0"
3
+ VERSION = "0.3.0"
4
4
  end
5
5
  end
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.2.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-08 00:00:00.000000000 Z
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