pause 0.2.1 → 0.4.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
- SHA1:
3
- metadata.gz: 6c71d4ddaa7df30afce53ce0c5f9cb355326d8f8
4
- data.tar.gz: 6ab9fbd9854b7d970bffc0e6436ca788f7303ac5
2
+ SHA256:
3
+ metadata.gz: 81a777526619b80e87ad3e11b6794c88634ea326cf34aad34ac70268517cdf4f
4
+ data.tar.gz: 17f95640fde2ddd98e6922b7cab715e8c7973b791a3e728c65f132265da55b78
5
5
  SHA512:
6
- metadata.gz: 6960d06f4e402f5f45c8f4c731db11783bce91aa0ba1e3910a41b5ef2a5c75d5365645b32d7a712c0607dfc41c943db489db9ef89f5932a8ceebefd1addaefc3
7
- data.tar.gz: 7a6b55e41e5cab30e2d586a190ca7cb12c7a4de691aba0c5698693418d427c9793b33d92d3d70125fdd71d5b6b9dbc92dc7a1bdd517f74e6178386d600983556
6
+ metadata.gz: f46245fe1330897e8bf6dee18d32b8cc561849f59d5c6049bd45360b5614d27a7f0e0caf8494e93cd6266a733750cdd3aad2559508364c9ee7990493fb0934de
7
+ data.tar.gz: c9fc4e23661001591aac93c7427e9e98ac03e6d5e15da060c537f43ce47b1eca3ac65658004ffff7e5706a59b1c98e7b7236329c1cd722e4348f2741acbd5e8c
data/.gitignore CHANGED
@@ -17,3 +17,5 @@ test/version_tmp
17
17
  tmp
18
18
  .idea
19
19
  .DS_Store
20
+ .ruby-version
21
+ .spec
@@ -1,10 +1,14 @@
1
1
  language: ruby
2
2
  rvm:
3
- - 1.9.3
4
- script: "bundle exec rspec"
3
+ - 2.3.3
4
+ - 2.4.3
5
+ - 2.5.0
6
+ script: "bundle exec rake || ( sleep 1; bundle exec rspec --only-failures ) "
7
+ services:
8
+ - redis-server
5
9
  notifications:
6
10
  email:
7
11
  recipients:
8
- - dev-info@wanelo.com
12
+ - kigster@gmail.com
9
13
  on_success: never
10
14
  on_failure: always
data/Gemfile CHANGED
@@ -5,11 +5,3 @@ source 'https://rubygems.org'
5
5
 
6
6
  # Specify your gem's dependencies in pause.gemspec
7
7
  gemspec
8
-
9
- gem 'fakeredis'
10
- gem 'guard-rspec'
11
- gem 'pry-nav'
12
- gem 'rake'
13
- gem 'rb-fsevent'
14
- gem 'rspec'
15
- gem 'timecop'
data/README.md CHANGED
@@ -1,17 +1,105 @@
1
- Pause
2
- ======
1
+ # Pause
3
2
 
4
3
  [![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)
4
+ [![Build Status](https://travis-ci.org/kigster/pause.svg?branch=master)](https://travis-ci.org/kigster/pause)
6
5
 
7
- Pause is a flexible Redis-backed rate-limiting client. Use it to track events, with
6
+ ## In a Nutshell
7
+
8
+ **Pause** is a fast and very flexible Redis-backed rate-limiter. You can use it to track events, with
8
9
  rules around how often they are allowed to occur within configured time checks.
9
10
 
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.
11
+ Sample applications include:
12
+
13
+ * throttling notifications sent to a user as to not overwhelm them with too much frequency,
14
+ * IP-based blocking based on HTTP request volume (see the related gem [spanx](https://github.com/wanelo/spanx)) that uses Pause,
15
+ * ensuring you do not exceed API rate limits when calling external web APIs.
16
+ * etc.
17
+
18
+ Pause currently does not offer a CLI client, and can only be used from within a Ruby application.
19
+
20
+ Additionally:
21
+
22
+ * Pause is pure-ruby gem and does not depend on Rails or Rack
23
+ * Pause can be used across multiple ruby processes, since it uses a distributed Redis backend
24
+ * Pause is currently in use by a web application receiving 6K-10K web requests per second
25
+ * 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.
26
+
27
+ ### Quick Start
28
+
29
+ This section is meant to give you a rapid introduction, so that you can start using Pause immediately.
30
+
31
+ Our use case: we want to rate limit notifications sent to users, identified by their `user_id`, to:
32
+
33
+ * no more than 1 in any 2-hour period
34
+ * no more than 3 per day
35
+ * no more than 7 per week
36
+
37
+ Here is how we could set this up using Pause:
38
+
39
+ #### Configuration
40
+
41
+ We need to setup Pause with a Redis instance. Here is how we do it:
42
+
43
+ ```ruby
44
+ require 'pause'
45
+
46
+ # First, lets point Pause to a Redis instance
47
+ Pause.configure do |config|
48
+ # Redis connection parameters
49
+ config.redis_host = '127.0.0.1'
50
+ config.redis_port = 6379
51
+ config.redis_db = 1
52
+
53
+ # aggregate all events into 10 minute blocks.
54
+ # Larger blocks require less RAM and CPU, smaller blocks are more
55
+ # computationally expensive.
56
+ config.resolution = 600
57
+
58
+ # discard all events older than 1 day
59
+ config.history = 86400
60
+ end
61
+ ```
62
+
63
+ #### Define Rate Limited "Action"
64
+
65
+ Next we must define the rate limited action based on the specification above. This is how easy it is:
66
+
67
+ ```ruby
68
+ module MyApp
69
+ class UserNotificationLimiter < ::Pause::Action
70
+ # this is a redis key namespace added to all data in this action
71
+ scope 'un'
72
+ check period_seconds: 120, max_allowed: 1
73
+ check period_seconds: 86400, max_allowed: 3
74
+ check period_seconds: 7 * 86400, max_allowed: 7
75
+ end
76
+ end
77
+ ```
78
+
79
+ #### Perform operation, but only if the user is not rate-limited
80
+
81
+ 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:
82
+
83
+ ```ruby
84
+ class NotificationsWorker
85
+ def perform(user_id)
86
+ limiter = MyApp::UserNotificationLimiter.new(user_id)
87
+
88
+ limiter.unless_rate_limited do
89
+ user = User.find(user_id)
90
+ user.send_push_notification!
91
+ end
92
+
93
+ # You can also do something in case the user is rate limited:
94
+ limiter.if_rate_limited do |rate_limit_event|
95
+ Rails.logger.info("user #{user.id} has exceeded rate limit: #{rate_limit_event}")
96
+ end
97
+ end
98
+ end
99
+ ```
100
+
101
+ That's it! Using these two methods you can pretty much ensure that your rate limits are always in check.
12
102
 
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
103
 
16
104
  ## Installation
17
105
 
@@ -86,47 +174,44 @@ In other words, if your shortest check is 1 minute, you could set resolution to
86
174
  require 'pause'
87
175
 
88
176
  class FollowAction < Pause::Action
89
- scope "f"
177
+ scope 'fa' # keep those short
90
178
  check period_seconds: 60, max_allowed: 100, block_ttl: 3600
91
179
  check period_seconds: 1800, max_allowed: 2000, block_ttl: 3600
92
180
  end
93
181
  ```
94
182
 
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.
183
+ 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.
184
+
185
+ 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
186
 
98
187
  ```ruby
99
188
  class FollowsController < ApplicationController
100
189
  def create
101
190
  action = FollowAction.new(user.id)
102
191
  if action.ok?
103
- # do stuff!
104
- # and track it...
192
+ user.follow!
193
+ # and don't forget to track the "success"
105
194
  action.increment!
106
- else
107
- # action is rate limited, either skip
108
- # or show error, depending on the context.
109
195
  end
110
196
  end
111
197
  end
112
198
 
113
199
  class OtherController < ApplicationController
114
200
  def index
115
- action = OtherAction.new(params[:thing])
201
+ action = OtherAction.new(params[:thing])d
116
202
  unless action.rate_limited?
117
203
  # perform business logic
118
- ....
119
- # track it
204
+ # but in this
120
205
  action.increment!(params[:count].to_i, Time.now.to_i)
121
206
  end
122
207
  end
123
208
  end
124
209
  ```
125
210
 
126
- If more data is needed about why the action is blocked, the `analyze` can be called
211
+ If more data is needed about why the action is blocked, the `analyze` can be called:
127
212
 
128
213
  ```ruby
129
- action = NotifyViaEmailAction.new("thing")
214
+ action = NotifyViaEmailAction.new(:thing)
130
215
 
131
216
  while true
132
217
  action.increment!
@@ -222,6 +307,26 @@ tracked identifiers.
222
307
 
223
308
  The action block list is implemented as a sorted set, so it should still be usable when sharding.
224
309
 
310
+ ## Testing
311
+
312
+ 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`.
313
+
314
+ Please note that Travis suite, as well as the default rake task, run both.
315
+
316
+ ### Unit Testing with Fakeredis
317
+
318
+ Fakeredis is the default, and is also run whenever `bundle exec rspec` is executed, or `rake spec` task invoked.
319
+
320
+ ```bash
321
+ bundle exec rake spec:unit
322
+ ```
323
+
324
+ ### Integration Testing with Redis
325
+
326
+ ```bash
327
+ bundle exec rake spec:integration
328
+ ```
329
+
225
330
  ## Contributing
226
331
 
227
332
  Want to make it better? Cool. Here's how:
data/Rakefile CHANGED
@@ -1,6 +1,47 @@
1
- require "bundler/gem_tasks"
1
+ require 'bundler/gem_tasks'
2
2
  require 'rspec/core/rake_task'
3
+ require 'yard'
3
4
 
4
5
  RSpec::Core::RakeTask.new(:spec)
5
6
 
6
- task :default => :spec
7
+ task :default => %w(spec:unit spec:integration)
8
+
9
+ namespace :spec do
10
+ desc 'Run specs using fakeredis'
11
+ task :unit do
12
+ ENV['PAUSE_REAL_REDIS'] = nil
13
+ Rake::Task['spec'].execute
14
+ end
15
+ desc 'Run specs against a local Redis server'
16
+ task :integration do
17
+ ENV['PAUSE_REAL_REDIS'] = 'true'
18
+ Rake::Task['spec'].execute
19
+ end
20
+ end
21
+
22
+ def shell(*args)
23
+ puts "running: #{args.join(' ')}"
24
+ system(args.join(' '))
25
+ end
26
+
27
+ task :clean do
28
+ shell('rm -rf pkg/ tmp/ coverage/ doc/ ' )
29
+ end
30
+
31
+ task :gem => [:build] do
32
+ shell('gem install pkg/*')
33
+ end
34
+
35
+ task :permissions => [ :clean ] do
36
+ shell('chmod -v o+r,g+r * */* */*/* */*/*/* */*/*/*/* */*/*/*/*/*')
37
+ shell("find . -type d -exec chmod o+x,g+x {} \\;")
38
+ end
39
+
40
+ task :build => :permissions
41
+
42
+ YARD::Rake::YardocTask.new(:doc) do |t|
43
+ t.files = %w(lib/**/*.rb exe/*.rb - README.md LICENSE.txt)
44
+ t.options.unshift('--title','"Pause - Redis-backed Rate Limiter"')
45
+ t.after = ->() { exec('open doc/index.html') }
46
+ end
47
+
@@ -37,11 +37,11 @@ module Pause
37
37
  end
38
38
 
39
39
  def configure(&block)
40
- @configuration = Pause::Configuration.new.configure(&block)
40
+ @configuration ||= Pause::Configuration.new.configure(&block)
41
41
  end
42
42
 
43
- def config
44
- @configuration
43
+ def config(&block)
44
+ configure(&block)
45
45
  end
46
46
  end
47
47
  end
@@ -3,60 +3,127 @@ module Pause
3
3
  attr_accessor :identifier
4
4
 
5
5
  def initialize(identifier)
6
- @identifier = identifier
7
- self.class.checks = [] unless self.class.instance_variable_get(:@checks)
6
+ @identifier = identifier
7
+ self.class.checks ||= []
8
8
  end
9
9
 
10
- # Action subclasses should define their scope as follows
11
- #
12
- # class MyAction < Pause::Action
13
- # scope "my:scope"
14
- # end
15
- #
16
10
  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
11
+ self.class.scope
12
+ end
13
+
14
+ class << self
15
+ attr_accessor :checks
16
+
17
+ def inherited(klass)
18
+ klass.instance_eval do
19
+ # Action subclasses should define their scope as follows
20
+ #
21
+ # class MyAction < Pause::Action
22
+ # scope "my:scope"
23
+ # end
24
+ #
25
+ @scope = klass.name.downcase.gsub(/::/, '.')
26
+ class << self
27
+
28
+ # @param [String] args
29
+ def scope(*args)
30
+ @scope = args.first if args && args.size == 1
31
+ @scope
32
+ end
33
+ end
44
34
  end
45
- @checks << Pause::PeriodCheck.new(period_seconds, max_allowed, block_ttl)
46
- end
47
-
48
- def self.checks
49
- @checks
35
+ end
36
+
37
+ # Actions can be globally disabled or re-enabled in a persistent
38
+ # way.
39
+ #
40
+ # MyAction.disable
41
+ # MyAction.enabled? => false
42
+ # MyAction.disabled? => true
43
+ #
44
+ # MyAction.enable
45
+ # MyAction.enabled? => true
46
+ # MyAction.disabled? => false
47
+ #
48
+ def enable
49
+ adapter.enable(scope)
50
+ end
51
+
52
+ def disable
53
+ adapter.disable(scope)
54
+ end
55
+
56
+ def enabled?
57
+ adapter.enabled?(scope)
58
+ end
59
+
60
+ def disabled?
61
+ !enabled?
62
+ end
63
+
64
+ # Action subclasses should define their checks as follows
65
+ #
66
+ # period_seconds - compare all activity by an identifier within the time period
67
+ # max_allowed - if the number of actions by an identifier exceeds max_allowed for the time period marked
68
+ # by period_seconds, it is no longer ok.
69
+ # block_ttl - time to mark identifier as not ok
70
+ #
71
+ # class MyAction < Pause::Action
72
+ # check period_seconds: 60, max_allowed: 100, block_ttl: 3600
73
+ # check period_seconds: 1800, max_allowed: 2000, block_ttl: 3600
74
+ # end
75
+ #
76
+ def check(*args, **opts)
77
+ self.checks ||= []
78
+
79
+ params =
80
+ if args.empty?
81
+ # if block_ttl is not provided, just default to the period
82
+ opts[:block_ttl] ||= opts[:period_seconds]
83
+ [opts[:period_seconds], opts[:max_allowed], opts[:block_ttl]]
84
+ else
85
+ args
86
+ end
87
+
88
+ self.checks << Pause::PeriodCheck.new(*params)
89
+ end
90
+
91
+ def tracked_identifiers
92
+ adapter.all_keys(scope)
93
+ end
94
+
95
+ def rate_limited_identifiers
96
+ adapter.rate_limited_keys(scope)
97
+ end
98
+
99
+ def unblock_all
100
+ adapter.delete_rate_limited_keys(scope)
101
+ end
102
+
103
+ def adapter
104
+ Pause.adapter
105
+ end
106
+ end
107
+
108
+ def unless_rate_limited(count: 1, timestamp: Time.now.to_i, &_block)
109
+ check_result = analyze
110
+ if check_result.nil?
111
+ yield
112
+ increment!(count, timestamp)
113
+ else
114
+ check_result
115
+ end
116
+ end
117
+
118
+ def if_rate_limited(&_block)
119
+ check_result = analyze(recalculate: true)
120
+ yield(check_result) unless check_result.nil?
50
121
  end
51
122
 
52
123
  def checks
53
124
  self.class.checks
54
125
  end
55
126
 
56
- def self.checks=(period_checks)
57
- @checks = period_checks
58
- end
59
-
60
127
  def block_for(ttl)
61
128
  adapter.rate_limit!(scope, identifier, ttl)
62
129
  end
@@ -66,7 +133,7 @@ module Pause
66
133
  end
67
134
 
68
135
  def rate_limited?
69
- ! ok?
136
+ !ok?
70
137
  end
71
138
 
72
139
  def ok?
@@ -76,65 +143,19 @@ module Pause
76
143
  false
77
144
  end
78
145
 
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)
146
+ def analyze(recalculate: false)
147
+ Pause.analyzer.check(self, recalculate: recalculate)
93
148
  end
94
149
 
95
150
  def unblock
96
151
  adapter.delete_rate_limited_key(scope, identifier)
97
152
  end
98
153
 
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
154
  private
127
155
 
128
- def self.adapter
129
- Pause.adapter
130
- end
131
-
132
156
  def adapter
133
157
  self.class.adapter
134
158
  end
135
159
 
136
- def self.class_scope
137
- class_variable_get:@@class_scope if class_variable_defined?(:@@class_scope)
138
- end
139
160
  end
140
161
  end