pause 0.2.1 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -1,17 +1,125 @@
1
- Pause
2
- ======
3
1
 
4
- [![Gem Version](https://badge.fury.io/rb/pause.png)](http://badge.fury.io/rb/pause)
5
- [![Build status](https://secure.travis-ci.org/wanelo/pause.png)](http://travis-ci.org/wanelo/pause)
2
+ [![RSpec](https://github.com/kigster/pause/actions/workflows/rspec.yml/badge.svg?style=for-the-badge)](https://github.com/kigster/pause/actions/workflows/rspec.yml)
3
+ [![Rubocop](https://github.com/kigster/pause/actions/workflows/rubocop.yml/badge.svg?style=for-the-badge)](https://github.com/kigster/pause/actions/workflows/rubocop.yml)
6
4
 
7
- Pause is a flexible Redis-backed rate-limiting client. Use it to track events, with
5
+ [![Gem Version](https://badge.fury.io/rb/pause@2x.png?icon=si%3Arubygems)](https://badge.fury.io/rb/pause)
6
+
7
+ [![Downloads](https://img.shields.io/gem/dt/pause.svg?style=for-the-badge&color=0AF)](https://rubygems.org/gems/pause)
8
+ [![License](https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge&color=0AF)](https://opensource.org/licenses/MIT)
9
+
10
+ # Pause
11
+
12
+ ## In a Nutshell
13
+
14
+ **Pause** is a fast and very flexible Redis-backed rate-limiter. You can use it to track events, with
8
15
  rules around how often they are allowed to occur within configured time checks.
9
16
 
10
- Because Pause is Redis-based, multiple ruby processes (even distributed across multiple servers) can track and report
11
- events together, and then query whether a particular identifier should be rate limited or not.
17
+ Sample applications include:
18
+
19
+ * throttling notifications sent to a user as to not overwhelm them with too much frequency,
20
+ * IP-based blocking based on HTTP request volume (see the related gem [spanx](https://github.com/wanelo/spanx)) that uses Pause,
21
+ * ensuring you do not exceed API rate limits when calling external web APIs.
22
+ * etc.
23
+
24
+ Pause currently does not offer a CLI client, and can only be used from within a Ruby application.
25
+
26
+ Additionally:
27
+
28
+ * Pause is pure-ruby gem and does not depend on Rails or Rack
29
+ * Pause can be used across multiple ruby processes, since it uses a distributed Redis backend
30
+ * Pause is currently in use by a web application receiving 6K-10K web requests per second
31
+ * Pause will work with a horizontally sharded multi-Redis-backend by using Twitter's [Twemproxy](https://github.com/twitter/twemproxy). This way, millions of concurrent users can be handled with ease.
32
+
33
+ ### Quick Start
34
+
35
+ This section is meant to give you a rapid introduction, so that you can start using Pause immediately.
36
+
37
+ Our use case: we want to rate limit notifications sent to users, identified by their `user_id`, to:
38
+
39
+ * no more than 1 in any 2-hour period
40
+ * no more than 3 per day
41
+ * no more than 7 per week
42
+
43
+ Here is how we could set this up using Pause:
44
+
45
+ #### Configuration
46
+
47
+ We need to setup Pause with a Redis instance. Here is how we do it:
48
+
49
+ ```ruby
50
+ require 'pause'
51
+
52
+ # First, lets point Pause to a Redis instance
53
+ Pause.configure do |config|
54
+ # Redis connection parameters
55
+ config.redis_host = '127.0.0.1'
56
+ config.redis_port = 6379
57
+ config.redis_db = 1
58
+ config.resolution = 600
59
+ config.history = 7 * 86400 # discard events older than 7 days
60
+ end
61
+ ```
62
+
63
+ > NOTE: **resolution** is an setting that's key to understanding how Pause works. It represents the length of time during which similar events are aggregated into a Hash-like object, where the key is the identifier, and the value is the count within that period.
64
+ >
65
+ > Because of this,
66
+ >
67
+ > * _Larger resolution requires less RAM and CPU and are faster to compute_
68
+ > * _Smaller resolution is more computationally expensive, but provides higher granularity_.
69
+ >
70
+ > The resolution setting must set to the smallest rate-limit period across all of your checks. Below it is set to 10 minutes, meaning that you can use Pause to **rate limit any event to no more than N times within a period of 10 minutes or more.**
71
+
72
+
73
+ #### Define Rate Limited "Action"
74
+
75
+ Next we must define the rate limited action based on the specification above. This is how easy it is:
76
+
77
+ ```ruby
78
+ module MyApp
79
+ class UserNotificationLimiter < ::Pause::Action
80
+ # this is a redis key namespace added to all data in this action
81
+ scope 'un'
82
+
83
+ check period_seconds: 120,
84
+ max_allowed: 1,
85
+ block_ttl: 240
86
+
87
+ check period_seconds: 86400,
88
+ max_allowed: 3
89
+
90
+ check period_seconds: 7 * 86400,
91
+ max_allowed: 7
92
+ end
93
+ end
94
+ ```
95
+
96
+ > NOTE: for each check, `block_ttl` defaults to `period_seconds`, and represents the duration of time the action will consider itself as "rate limited" after a particular check reaches the limit. Note, that all actions will automatically leave the "rate limited" state after `block_ttl` seconds have passed.
97
+
98
+ #### Perform operation, but only if the user is not rate-limited
99
+
100
+ Now we simply instantiate this limiter by passing user ID (any unique identifier works). We can then ask the limiter, `ok?` or `rate_limited?`, or we can use two convenient methods that only execute enclosed block if the described condition is satisfied:
101
+
102
+ ```ruby
103
+ class NotificationsWorker
104
+ def perform(user_id)
105
+ MyApp::UserNotificationLimiter.new(user_id) do
106
+ unless_rate_limited do
107
+ # this block ONLY runs if rate limit is not reached
108
+ user = User.find(user_id)
109
+ PushNotifications.new(user).send_push_notification!
110
+ end
111
+
112
+ if_rate_limited do |rate_limit_event|
113
+ # this block ONLY runs if the action has reached it's rate limit.
114
+ Rails.logger.info("user #{user.id} has exceeded rate limit: #{rate_limit_event}")
115
+ end
116
+ end
117
+ end
118
+ end
119
+ ```
120
+
121
+ That's it! Using these two methods you can pretty much ensure that your rate limits are always in check.
12
122
 
13
- Sample applications include IP-based blocking based on HTTP request volume (see related gem "spanx"),
14
- throttling push notifications as to not overwhelm the user with too much frequency, etc.
15
123
 
16
124
  ## Installation
17
125
 
@@ -86,47 +194,44 @@ In other words, if your shortest check is 1 minute, you could set resolution to
86
194
  require 'pause'
87
195
 
88
196
  class FollowAction < Pause::Action
89
- scope "f"
197
+ scope 'fa' # keep those short
90
198
  check period_seconds: 60, max_allowed: 100, block_ttl: 3600
91
199
  check period_seconds: 1800, max_allowed: 2000, block_ttl: 3600
92
200
  end
93
201
  ```
94
202
 
95
- When an event occurs, you increment an instance of your action, optionally with a timestamp and count. This saves
96
- data into a redis store, so it can be checked later by other processes. Timestamps should be in unix epoch format.
203
+ When an event occurs, you increment an instance of your action, optionally with a timestamp and count. This saves data into a redis store, so it can be checked later by other processes. Timestamps should be in unix epoch format.
204
+
205
+ In the example at the top of the README you saw how we used `#unless_rate_limited` and `#if_rate_limited` methods. These are the recommended API methods, but if you must get a finer-grained control over the actions, you can also use methods such as `#ok?`, `#rate_limited?`, `#increment!` to do manually what the block methods do already. Below is an example of this "manual" implementation:
97
206
 
98
207
  ```ruby
99
208
  class FollowsController < ApplicationController
100
209
  def create
101
210
  action = FollowAction.new(user.id)
102
211
  if action.ok?
103
- # do stuff!
104
- # and track it...
212
+ user.follow!
213
+ # and don't forget to track the "success"
105
214
  action.increment!
106
- else
107
- # action is rate limited, either skip
108
- # or show error, depending on the context.
109
215
  end
110
216
  end
111
217
  end
112
218
 
113
219
  class OtherController < ApplicationController
114
220
  def index
115
- action = OtherAction.new(params[:thing])
221
+ action = OtherAction.new(params[:thing])d
116
222
  unless action.rate_limited?
117
223
  # perform business logic
118
- ....
119
- # track it
224
+ # but in this
120
225
  action.increment!(params[:count].to_i, Time.now.to_i)
121
226
  end
122
227
  end
123
228
  end
124
229
  ```
125
230
 
126
- If more data is needed about why the action is blocked, the `analyze` can be called
231
+ If more data is needed about why the action is blocked, the `analyze` can be called:
127
232
 
128
233
  ```ruby
129
- action = NotifyViaEmailAction.new("thing")
234
+ action = NotifyViaEmailAction.new(:thing)
130
235
 
131
236
  while true
132
237
  action.increment!
@@ -216,12 +321,30 @@ Pause.configure do |config|
216
321
  end
217
322
  ```
218
323
 
219
- With this configuration, any Pause operation that we know is not supported by Twemproxy will raise
220
- `Pause::Redis::OperationNotSupported`. For instance, when sharding we are unable to get a list of all
221
- tracked identifiers.
324
+ With this configuration, any Pause operation that we know is not supported by Twemproxy will raise `Pause::Redis::OperationNotSupported`. For instance, when sharding we are unable to get a list of all tracked identifiers.
222
325
 
223
326
  The action block list is implemented as a sorted set, so it should still be usable when sharding.
224
327
 
328
+ ## Testing
329
+
330
+ By default, `fakeredis` gem is used to emulate Redis in development. However, the same test-suite should be able to run against a real redis — however, be aware that it will flush the current db during spec run. In order to run specs against real redis, make sure you have Redis running locally on the default port, and that you are able to connect to it using `redis-cli`.
331
+
332
+ Please note that Travis suite, as well as the default rake task, run both.
333
+
334
+ ### Unit Testing with Fakeredis
335
+
336
+ Fakeredis is the default, and is also run whenever `bundle exec rspec` is executed, or `rake spec` task invoked.
337
+
338
+ ```bash
339
+ bundle exec rake spec:unit
340
+ ```
341
+
342
+ ### Integration Testing with Redis
343
+
344
+ ```bash
345
+ bundle exec rake spec:integration
346
+ ```
347
+
225
348
  ## Contributing
226
349
 
227
350
  Want to make it better? Cool. Here's how:
@@ -234,8 +357,10 @@ Want to make it better? Cool. Here's how:
234
357
 
235
358
  ## Authors
236
359
 
237
- This gem was written by Eric Saxby, Atasay Gokkaya and Konstantin Gredeskoul at Wanelo, Inc.
360
+ * This gem was written by Eric Saxby, Atasay Gokkaya and Konstantin Gredeskoul at Wanelo, Inc.
361
+ * It's been updated and refreshed by Konstantin Gredeskoul.
362
+
238
363
 
239
- Please see the LICENSE.txt file for further details.
364
+ Please see the [LICENSE.txt](LICENSE.txt) file for further details.
240
365
 
241
366
 
data/Rakefile CHANGED
@@ -1,6 +1,48 @@
1
- require "bundler/gem_tasks"
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
2
4
  require 'rspec/core/rake_task'
5
+ require 'yard'
3
6
 
4
7
  RSpec::Core::RakeTask.new(:spec)
5
8
 
6
- task :default => :spec
9
+ task default: %w[spec:unit spec:integration]
10
+
11
+ namespace :spec do
12
+ desc 'Run specs using fakeredis'
13
+ task :unit do
14
+ ENV['PAUSE_REAL_REDIS'] = nil
15
+ Rake::Task['spec'].execute
16
+ end
17
+ desc 'Run specs against a local Redis server'
18
+ task :integration do
19
+ ENV['PAUSE_REAL_REDIS'] = 'true'
20
+ Rake::Task['spec'].execute
21
+ end
22
+ end
23
+
24
+ def shell(*args)
25
+ puts "running: #{args.join(' ')}"
26
+ system(args.join(' '))
27
+ end
28
+
29
+ task :clean do
30
+ shell('rm -rf pkg/ tmp/ coverage/ doc/ ')
31
+ end
32
+
33
+ task gem: [:build] do
34
+ shell('gem install pkg/*')
35
+ end
36
+
37
+ task permissions: [:clean] do
38
+ shell('chmod -v o+r,g+r * */* */*/* */*/*/* */*/*/*/* */*/*/*/*/*')
39
+ shell('find . -type d -exec chmod o+x,g+x {} \\;')
40
+ end
41
+
42
+ task build: :permissions
43
+
44
+ YARD::Rake::YardocTask.new(:doc) do |t|
45
+ t.files = %w[lib/**/*.rb exe/*.rb - README.md LICENSE.txt]
46
+ t.options.unshift('--title', '"Pause - Redis-backed Rate Limiter"')
47
+ t.after = -> { exec('open doc/index.html') }
48
+ end
data/bin/spec ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env bash
2
+
3
+ retry-errors() {
4
+ sleep 1
5
+ bundle exec rspec --only-failures
6
+ }
7
+
8
+ specs() {
9
+ bundle exec rspec && \
10
+ PAUSE_REAL_REDIS=true bundle exec rspec
11
+ }
12
+
13
+ specs || retry-errors
14
+
data/lib/pause/action.rb CHANGED
@@ -1,62 +1,131 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Pause
2
4
  class Action
3
5
  attr_accessor :identifier
4
6
 
5
- def initialize(identifier)
7
+ def initialize(identifier, &block)
6
8
  @identifier = identifier
7
- self.class.checks = [] unless self.class.instance_variable_get(:@checks)
9
+ self.class.checks ||= []
10
+ instance_exec(&block) if block
8
11
  end
9
12
 
10
- # Action subclasses should define their scope as follows
11
- #
12
- # class MyAction < Pause::Action
13
- # scope "my:scope"
14
- # end
15
- #
16
13
  def scope
17
- raise 'Should implement scope. (Ex: ipn:follow)'
18
- end
19
-
20
- def self.scope(scope_identifier = nil)
21
- class_variable_set(:@@class_scope, scope_identifier)
22
- define_method(:scope) { scope_identifier }
23
- end
24
-
25
- # Action subclasses should define their checks as follows
26
- #
27
- # period_seconds - compare all activity by an identifier within the time period
28
- # max_allowed - if the number of actions by an identifier exceeds max_allowed for the time period marked
29
- # by period_seconds, it is no longer ok.
30
- # block_ttl - time to mark identifier as not ok
31
- #
32
- # class MyAction < Pause::Action
33
- # check period_seconds: 60, max_allowed: 100, block_ttl: 3600
34
- # check period_seconds: 1800, max_allowed: 2000, block_ttl: 3600
35
- # end
36
- #
37
- def self.check(*args)
38
- @checks ||= []
39
- period_seconds, max_allowed, block_ttl =
40
- if args.first.is_a?(Hash)
41
- [args.first[:period_seconds], args.first[:max_allowed], args.first[:block_ttl]]
42
- else
43
- args
14
+ self.class.scope
15
+ end
16
+
17
+ class << self
18
+ attr_accessor :checks
19
+
20
+ def inherited(klass)
21
+ klass.instance_eval do
22
+ # Action subclasses should define their scope as follows
23
+ #
24
+ # class MyAction < Pause::Action
25
+ # scope "my:scope"
26
+ # end
27
+ #
28
+ @scope = klass.name.downcase.gsub('::', '.')
29
+ class << self
30
+ # @param [String] args
31
+ def scope(*args)
32
+ @scope = args.first if args && args.size == 1
33
+ @scope
34
+ end
35
+ end
44
36
  end
45
- @checks << Pause::PeriodCheck.new(period_seconds, max_allowed, block_ttl)
46
- end
47
-
48
- def self.checks
49
- @checks
37
+ end
38
+
39
+ # Actions can be globally disabled or re-enabled in a persistent
40
+ # way.
41
+ #
42
+ # MyAction.disable
43
+ # MyAction.enabled? => false
44
+ # MyAction.disabled? => true
45
+ #
46
+ # MyAction.enable
47
+ # MyAction.enabled? => true
48
+ # MyAction.disabled? => false
49
+ #
50
+ def enable
51
+ adapter.enable(scope)
52
+ end
53
+
54
+ def disable
55
+ adapter.disable(scope)
56
+ end
57
+
58
+ def enabled?
59
+ adapter.enabled?(scope)
60
+ end
61
+
62
+ def disabled?
63
+ !enabled?
64
+ end
65
+
66
+ # Action subclasses should define their checks as follows
67
+ #
68
+ # period_seconds - compare all activity by an identifier within the time period
69
+ # max_allowed - if the number of actions by an identifier exceeds max_allowed for the time period marked
70
+ # by period_seconds, it is no longer ok.
71
+ # block_ttl - time to mark identifier as not ok
72
+ #
73
+ # class MyAction < Pause::Action
74
+ # check period_seconds: 60, max_allowed: 100, block_ttl: 3600
75
+ # check period_seconds: 1800, max_allowed: 2000, block_ttl: 3600
76
+ # end
77
+ #
78
+ def check(*args, **opts)
79
+ self.checks ||= []
80
+
81
+ params =
82
+ if args.empty?
83
+ # if block_ttl is not provided, just default to the period
84
+ opts[:block_ttl] ||= opts[:period_seconds]
85
+ [opts[:period_seconds], opts[:max_allowed], opts[:block_ttl]]
86
+ else
87
+ args
88
+ end
89
+
90
+ self.checks << Pause::PeriodCheck.new(*params)
91
+ end
92
+
93
+ def tracked_identifiers
94
+ adapter.all_keys(scope)
95
+ end
96
+
97
+ def rate_limited_identifiers
98
+ adapter.rate_limited_keys(scope)
99
+ end
100
+
101
+ def unblock_all
102
+ adapter.delete_rate_limited_keys(scope)
103
+ end
104
+
105
+ def adapter
106
+ Pause.adapter
107
+ end
108
+ end
109
+
110
+ def unless_rate_limited(count: 1, timestamp: Time.now.to_i, &_block)
111
+ check_result = analyze
112
+ if check_result.nil?
113
+ yield
114
+ increment!(count, timestamp)
115
+ else
116
+ check_result
117
+ end
118
+ end
119
+
120
+ def if_rate_limited(&_block)
121
+ check_result = analyze(recalculate: true)
122
+ yield(check_result) unless check_result.nil?
50
123
  end
51
124
 
52
125
  def checks
53
126
  self.class.checks
54
127
  end
55
128
 
56
- def self.checks=(period_checks)
57
- @checks = period_checks
58
- end
59
-
60
129
  def block_for(ttl)
61
130
  adapter.rate_limit!(scope, identifier, ttl)
62
131
  end
@@ -66,7 +135,7 @@ module Pause
66
135
  end
67
136
 
68
137
  def rate_limited?
69
- ! ok?
138
+ !ok?
70
139
  end
71
140
 
72
141
  def ok?
@@ -76,65 +145,18 @@ module Pause
76
145
  false
77
146
  end
78
147
 
79
- def analyze
80
- Pause.analyzer.check(self)
81
- end
82
-
83
- def self.tracked_identifiers
84
- adapter.all_keys(self.class_scope)
85
- end
86
-
87
- def self.rate_limited_identifiers
88
- adapter.rate_limited_keys(self.class_scope)
89
- end
90
-
91
- def self.unblock_all
92
- adapter.delete_rate_limited_keys(self.class_scope)
148
+ def analyze(recalculate: false)
149
+ Pause.analyzer.check(self, recalculate: recalculate)
93
150
  end
94
151
 
95
152
  def unblock
96
153
  adapter.delete_rate_limited_key(scope, identifier)
97
154
  end
98
155
 
99
- # Actions can be globally disabled or re-enabled in a persistent
100
- # way.
101
- #
102
- # MyAction.disable
103
- # MyAction.enabled? => false
104
- # MyAction.disabled? => true
105
- #
106
- # MyAction.enable
107
- # MyAction.enabled? => true
108
- # MyAction.disabled? => false
109
- #
110
- def self.enable
111
- adapter.enable(class_scope)
112
- end
113
-
114
- def self.disable
115
- adapter.disable(class_scope)
116
- end
117
-
118
- def self.enabled?
119
- adapter.enabled?(class_scope)
120
- end
121
-
122
- def self.disabled?
123
- ! enabled?
124
- end
125
-
126
156
  private
127
157
 
128
- def self.adapter
129
- Pause.adapter
130
- end
131
-
132
158
  def adapter
133
159
  self.class.adapter
134
160
  end
135
-
136
- def self.class_scope
137
- class_variable_get:@@class_scope if class_variable_defined?(:@@class_scope)
138
- end
139
161
  end
140
162
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'pause/helper/timing'
2
4
 
3
5
  module Pause
@@ -10,14 +12,16 @@ module Pause
10
12
  # @return [nil] everything is fine
11
13
  # @return [false] this action is already blocked
12
14
  # @return [Pause::RateLimitedEvent] the action was blocked as a result of this check
13
- def check(action)
14
- return false if adapter.rate_limited?(action.scope, action.identifier)
15
+ def check(action, recalculate: false)
16
+ return false if adapter.rate_limited?(action.scope, action.identifier) && !recalculate
17
+
15
18
  timestamp = period_marker(Pause.config.resolution, Time.now.to_i)
16
- set = adapter.key_history(action.scope, action.identifier)
19
+ set = adapter.key_history(action.scope, action.identifier)
17
20
  action.checks.each do |period_check|
18
21
  start_time = timestamp - period_check.period_seconds
19
22
  set.reverse.inject(0) do |sum, element|
20
23
  break if element.ts < start_time
24
+
21
25
  sum += element.count
22
26
  if sum >= period_check.max_allowed
23
27
  adapter.rate_limit!(action.scope, action.identifier, period_check.block_ttl)
@@ -1,9 +1,11 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Pause
2
4
  class Configuration
3
5
  attr_writer :redis_host, :redis_port, :redis_db, :resolution, :history, :sharded
4
6
 
5
7
  def configure
6
- yield self
8
+ yield self if block_given?
7
9
  self
8
10
  end
9
11
 
@@ -24,7 +26,7 @@ module Pause
24
26
  end
25
27
 
26
28
  def history
27
- (@history || 86400).to_i
29
+ (@history || 86_400).to_i
28
30
  end
29
31
 
30
32
  def sharded
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Pause
2
4
  module Helper
3
5
  module Timing
data/lib/pause/logger.rb CHANGED
@@ -1,11 +1,18 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Pause
4
+ # @description Logger class for Pause
2
5
  class Logger
3
- def self.puts message
4
- STDOUT.puts message
5
- end
6
+ class << self
7
+ def puts(message)
8
+ $stdout.puts message
9
+ end
6
10
 
7
- def self.fatal message
8
- STDERR.puts message
11
+ def fatal(message)
12
+ # rubocop: disable Style/StderrPuts
13
+ $stderr.puts message.red
14
+ # rubocop: enable Style/StderrPuts
15
+ end
9
16
  end
10
17
  end
11
18
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Pause
2
4
  class RateLimitedEvent
3
5
  attr_accessor :action, :identifier, :period_check, :sum, :timestamp
@@ -9,6 +11,5 @@ module Pause
9
11
  @sum = sum
10
12
  @timestamp = timestamp
11
13
  end
12
-
13
14
  end
14
15
  end