activejob-retry 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 9dd35a0525fd3f160a488f90b9471f7058a714de
4
+ data.tar.gz: 3e423dbca62f4337a765a54f7c212ccd3ef237eb
5
+ SHA512:
6
+ metadata.gz: ac1daad64696deacbfeab3d8e28e1ffff49b7e219580de47f9f02f0867ae5f0977615c019d88f8f9d2afca3561df7b551fcb56a9b3d4ce2ccdea109e117c907a
7
+ data.tar.gz: 74b33077ae43b9232eace312851fc9a85165e72c8ae4b1b386f97d109daab75121909fb9a9b7bcec5c7b3e70673ca0d4ab5ed5a16448d7ab7d8769b0b95a7e50
@@ -0,0 +1,5 @@
1
+ .bundle
2
+ .gem
3
+ .rbx/
4
+ *.gem
5
+ .ruby-version
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --require spec_helper
@@ -0,0 +1,15 @@
1
+ language: ruby
2
+
3
+ matrix:
4
+ allow_failures:
5
+ - rvm: jruby-19mode
6
+ - rvm: rbx-2
7
+
8
+ rvm:
9
+ - 1.9.3
10
+ - 2.0.0
11
+ - 2.1.0
12
+ - 2.1.1
13
+ - 2.1.2
14
+ - jruby-19mode
15
+ - rbx-2
File without changes
data/Gemfile ADDED
@@ -0,0 +1,17 @@
1
+ source 'https://rubygems.org'
2
+ gemspec
3
+
4
+ group :test do
5
+ gem 'resque', require: false
6
+ gem 'resque-scheduler', require: false
7
+ gem 'sidekiq', require: false
8
+ gem 'sucker_punch', require: false
9
+ gem 'delayed_job', require: false
10
+ gem 'queue_classic', "< 3.0.0", require: false, platforms: :ruby
11
+ gem 'sneakers', '0.1.1.pre', require: false
12
+ gem 'que', require: false
13
+ gem 'backburner', require: false
14
+ gem 'qu-rails', github: "bkeepers/qu", branch: "master", require: false
15
+ gem 'qu-redis', require: false
16
+ gem 'sequel', require: false
17
+ end
@@ -0,0 +1,197 @@
1
+ GIT
2
+ remote: git://github.com/bkeepers/qu.git
3
+ revision: d098e2657c92e89a6413bebd9c033930759c061f
4
+ branch: master
5
+ specs:
6
+ qu (0.2.0)
7
+ qu-rails (0.2.0)
8
+ qu (= 0.2.0)
9
+ railties (>= 3.2, < 5)
10
+ qu-redis (0.2.0)
11
+ qu (= 0.2.0)
12
+ redis-namespace
13
+
14
+ PATH
15
+ remote: .
16
+ specs:
17
+ activejob-retry (0.0.1)
18
+ activejob (>= 4.2)
19
+ activesupport (>= 4.2)
20
+
21
+ GEM
22
+ remote: https://rubygems.org/
23
+ specs:
24
+ actionpack (4.2.0)
25
+ actionview (= 4.2.0)
26
+ activesupport (= 4.2.0)
27
+ rack (~> 1.6.0)
28
+ rack-test (~> 0.6.2)
29
+ rails-dom-testing (~> 1.0, >= 1.0.5)
30
+ rails-html-sanitizer (~> 1.0, >= 1.0.1)
31
+ actionview (4.2.0)
32
+ activesupport (= 4.2.0)
33
+ builder (~> 3.1)
34
+ erubis (~> 2.7.0)
35
+ rails-dom-testing (~> 1.0, >= 1.0.5)
36
+ rails-html-sanitizer (~> 1.0, >= 1.0.1)
37
+ activejob (4.2.0)
38
+ activesupport (= 4.2.0)
39
+ globalid (>= 0.3.0)
40
+ activesupport (4.2.0)
41
+ i18n (~> 0.7)
42
+ json (~> 1.7, >= 1.7.7)
43
+ minitest (~> 5.1)
44
+ thread_safe (~> 0.3, >= 0.3.4)
45
+ tzinfo (~> 1.1)
46
+ amq-protocol (1.9.2)
47
+ backburner (0.4.6)
48
+ beaneater (~> 0.3.1)
49
+ dante (~> 0.1.5)
50
+ beaneater (0.3.3)
51
+ builder (3.2.2)
52
+ bunny (1.1.9)
53
+ amq-protocol (>= 1.9.2)
54
+ byebug (3.5.1)
55
+ columnize (~> 0.8)
56
+ debugger-linecache (~> 1.2)
57
+ slop (~> 3.6)
58
+ celluloid (0.16.0)
59
+ timers (~> 4.0.0)
60
+ coderay (1.1.0)
61
+ columnize (0.9.0)
62
+ connection_pool (2.1.0)
63
+ dante (0.1.5)
64
+ debugger-linecache (1.2.0)
65
+ delayed_job (4.0.6)
66
+ activesupport (>= 3.0, < 5.0)
67
+ diff-lcs (1.2.5)
68
+ erubis (2.7.0)
69
+ globalid (0.3.0)
70
+ activesupport (>= 4.1.0)
71
+ hitimes (1.2.2)
72
+ i18n (0.7.0)
73
+ json (1.8.1)
74
+ loofah (2.0.1)
75
+ nokogiri (>= 1.5.9)
76
+ method_source (0.8.2)
77
+ mini_portile (0.6.2)
78
+ minitest (5.5.0)
79
+ mono_logger (1.1.0)
80
+ multi_json (1.10.1)
81
+ nokogiri (1.6.5)
82
+ mini_portile (~> 0.6.0)
83
+ pg (0.17.1)
84
+ pry (0.10.1)
85
+ coderay (~> 1.1.0)
86
+ method_source (~> 0.8.1)
87
+ slop (~> 3.4)
88
+ pry-byebug (2.0.0)
89
+ byebug (~> 3.4)
90
+ pry (~> 0.10)
91
+ que (0.9.0)
92
+ queue_classic (2.2.3)
93
+ pg (~> 0.17.0)
94
+ rack (1.6.0)
95
+ rack-protection (1.5.3)
96
+ rack
97
+ rack-test (0.6.2)
98
+ rack (>= 1.0)
99
+ rails-deprecated_sanitizer (1.0.3)
100
+ activesupport (>= 4.2.0.alpha)
101
+ rails-dom-testing (1.0.5)
102
+ activesupport (>= 4.2.0.beta, < 5.0)
103
+ nokogiri (~> 1.6.0)
104
+ rails-deprecated_sanitizer (>= 1.0.1)
105
+ rails-html-sanitizer (1.0.1)
106
+ loofah (~> 2.0)
107
+ railties (4.2.0)
108
+ actionpack (= 4.2.0)
109
+ activesupport (= 4.2.0)
110
+ rake (>= 0.8.7)
111
+ thor (>= 0.18.1, < 2.0)
112
+ rake (10.4.2)
113
+ redis (3.2.0)
114
+ redis-namespace (1.5.1)
115
+ redis (~> 3.0, >= 3.0.4)
116
+ resque (1.25.2)
117
+ mono_logger (~> 1.0)
118
+ multi_json (~> 1.0)
119
+ redis-namespace (~> 1.3)
120
+ sinatra (>= 0.9.2)
121
+ vegas (~> 0.1.2)
122
+ resque-scheduler (4.0.0)
123
+ mono_logger (~> 1.0)
124
+ redis (~> 3.0)
125
+ resque (~> 1.25)
126
+ rufus-scheduler (~> 3.0)
127
+ rspec (3.1.0)
128
+ rspec-core (~> 3.1.0)
129
+ rspec-expectations (~> 3.1.0)
130
+ rspec-mocks (~> 3.1.0)
131
+ rspec-core (3.1.7)
132
+ rspec-support (~> 3.1.0)
133
+ rspec-expectations (3.1.2)
134
+ diff-lcs (>= 1.2.0, < 2.0)
135
+ rspec-support (~> 3.1.0)
136
+ rspec-its (1.1.0)
137
+ rspec-core (>= 3.0.0)
138
+ rspec-expectations (>= 3.0.0)
139
+ rspec-mocks (3.1.3)
140
+ rspec-support (~> 3.1.0)
141
+ rspec-support (3.1.2)
142
+ rufus-scheduler (3.0.9)
143
+ tzinfo
144
+ sequel (4.17.0)
145
+ serverengine (1.5.10)
146
+ sigdump (~> 0.2.2)
147
+ sidekiq (3.3.0)
148
+ celluloid (>= 0.16.0)
149
+ connection_pool (>= 2.0.0)
150
+ json
151
+ redis (>= 3.0.6)
152
+ redis-namespace (>= 1.3.1)
153
+ sigdump (0.2.2)
154
+ sinatra (1.4.5)
155
+ rack (~> 1.4)
156
+ rack-protection (~> 1.4)
157
+ tilt (~> 1.3, >= 1.3.4)
158
+ slop (3.6.0)
159
+ sneakers (0.1.1.pre)
160
+ bunny (~> 1.1.3)
161
+ serverengine
162
+ thor
163
+ thread
164
+ sucker_punch (1.3.2)
165
+ celluloid (~> 0.16.0)
166
+ thor (0.19.1)
167
+ thread (0.1.4)
168
+ thread_safe (0.3.4)
169
+ tilt (1.4.1)
170
+ timers (4.0.1)
171
+ hitimes
172
+ tzinfo (1.2.2)
173
+ thread_safe (~> 0.1)
174
+ vegas (0.1.11)
175
+ rack (>= 1.0.0)
176
+
177
+ PLATFORMS
178
+ ruby
179
+
180
+ DEPENDENCIES
181
+ activejob-retry!
182
+ backburner
183
+ delayed_job
184
+ pry-byebug
185
+ qu-rails!
186
+ qu-redis
187
+ que
188
+ queue_classic (< 3.0.0)
189
+ rake (>= 10.3)
190
+ resque
191
+ resque-scheduler
192
+ rspec
193
+ rspec-its
194
+ sequel
195
+ sidekiq
196
+ sneakers (= 0.1.1.pre)
197
+ sucker_punch
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Isaac Seymour
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ Software), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,47 @@
1
+ ActiveJob::Retry
2
+ ================
3
+
4
+ A DSL for automatically retrying ActiveJobs, with exponential backoff.
5
+
6
+ ```ruby
7
+ class ProcessWebhook < ActiveJob::Base
8
+ include ActiveJob::Retry
9
+
10
+ queue_as :webhooks
11
+ retry_with limit: 3, # Attempt three times and then raise (default: 1)
12
+ delay: 5, # Wait ~5 seconds between attempts (default: 0)
13
+ retry_exceptions: [TimeoutError] # Only retry when these errors are raised (default: all)
14
+ # Could alternatively use:
15
+ # fatal_exceptions: [StandardError] # Never catch these errors (default: none)
16
+
17
+ def perform(webhook)
18
+ webhook.process!
19
+ end
20
+ end
21
+ ```
22
+
23
+ With exponential backoff:
24
+
25
+ ```ruby
26
+ class ProcessWebhook < ActiveJob::Base
27
+ include ActiveJob::Retry::ExponentialBackoff
28
+
29
+ queue_as :webhooks
30
+ backoff_with strategy: [1, 5, 10, 30, 60] # Delay for 1, 5, ... seconds between subsequent retries
31
+ min_delay_multiplier: 0.8, # Multiply each delay by a random number between
32
+ max_delay_multiplier: 1.2 # 0.8 and 1.2 (rounded to nearest second)
33
+
34
+ def perform(webhook)
35
+ webhook.process!
36
+ end
37
+ end
38
+ ```
39
+
40
+ Contributing
41
+ ------------
42
+
43
+ * Fork the project.
44
+ * Make your feature addition or bug fix.
45
+ * Add tests for it.
46
+ * Document it in the CHANGELOG.md.
47
+ * Open a PR.
@@ -0,0 +1,75 @@
1
+ require 'rake/testtask'
2
+ require 'rubygems/package_task'
3
+
4
+ ACTIVEJOB_ADAPTERS = %w(backburner delayed_job que resque sidekiq)
5
+
6
+ task default: :test
7
+ task test: 'test:default'
8
+
9
+ namespace :test do
10
+ desc 'Run all adapter tests'
11
+ task :default do
12
+ run_without_aborting ACTIVEJOB_ADAPTERS.map { |a| "test:#{a}" }
13
+ end
14
+
15
+ desc 'Run all adapter tests in isolation'
16
+ task :isolated do
17
+ run_without_aborting ACTIVEJOB_ADAPTERS.map { |a| "test:isolated:#{a}" }
18
+ end
19
+
20
+ desc 'Run integration tests for all adapters'
21
+ task :integration do
22
+ run_without_aborting ACTIVEJOB_ADAPTERS.map { |a| "test:integration:#{a}" }
23
+ end
24
+
25
+ task 'env:integration' do
26
+ ENV['AJ_INTEGRATION_TESTS'] = "1"
27
+ end
28
+
29
+ ACTIVEJOB_ADAPTERS.each do |adapter|
30
+ task("env:#{adapter}") { ENV['AJADAPTER'] = adapter }
31
+
32
+ Rake::TestTask.new(adapter => "test:env:#{adapter}") do |t|
33
+ t.description = "Run adapter tests for #{adapter}"
34
+ t.libs << 'test'
35
+ t.test_files = FileList['test/cases/**/*_test.rb']
36
+ t.verbose = true
37
+ t.warning = true
38
+ t.ruby_opts = ["--dev"] if defined?(JRUBY_VERSION)
39
+ end
40
+
41
+ namespace :isolated do
42
+ task adapter => "test:env:#{adapter}" do
43
+ dir = File.dirname(__FILE__)
44
+ Dir.glob("#{dir}/test/cases/**/*_test.rb").all? do |file|
45
+ sh(Gem.ruby, '-w', "-I#{dir}/lib", "-I#{dir}/test", file)
46
+ end or raise 'Failures'
47
+ end
48
+ end
49
+
50
+ namespace :integration do
51
+ Rake::TestTask.new(adapter => ["test:env:#{adapter}", 'test:env:integration']) do |t|
52
+ t.description = "Run integration tests for #{adapter}"
53
+ t.libs << 'test'
54
+ t.test_files = FileList['test/integration/**/*_test.rb']
55
+ t.verbose = true
56
+ t.warning = true
57
+ t.ruby_opts = ["--dev"] if defined?(JRUBY_VERSION)
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ def run_without_aborting(tasks)
64
+ errors = []
65
+
66
+ tasks.each do |task|
67
+ begin
68
+ Rake::Task[task].invoke
69
+ rescue Exception
70
+ errors << task
71
+ end
72
+ end
73
+
74
+ abort "Errors running #{errors.join(', ')}" if errors.any?
75
+ end
@@ -0,0 +1,38 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'active_job/retry/version'
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = 'activejob-retry'
8
+ s.version = ActiveJob::Retry::VERSION
9
+ s.date = Date.today.strftime('%Y-%m-%d')
10
+ s.authors = ['Isaac Seymour']
11
+ s.email = ['isaac@isaacseymour.co.uk']
12
+ s.summary = 'Automatic retrying DSL for ActiveJob'
13
+ s.description = <<-EOL
14
+ activejob-retry provides a simple DSL for automatically retrying ActiveJobs when they
15
+ fail, with exponential backoff.
16
+
17
+ Features:
18
+
19
+ * (Should) work with any queue adapter that supports retries.
20
+ * Whitelist/blacklist exceptions to retry on.
21
+ * Exponential backoff (varying the delay between retries).
22
+ * Light and easy to override retry logic.
23
+ EOL
24
+ s.homepage = 'http://github.com/isaacseymour/activejob-retry'
25
+ s.license = 'MIT'
26
+
27
+ s.has_rdoc = false
28
+ s.files = `git ls-files`.split($/)
29
+ s.require_paths = %w[lib]
30
+
31
+ s.add_dependency('activejob', '>= 4.2')
32
+ s.add_dependency('activesupport', '>= 4.2')
33
+
34
+ s.add_development_dependency('rake', ' >= 10.3')
35
+ s.add_development_dependency('rspec')
36
+ s.add_development_dependency('rspec-its')
37
+ s.add_development_dependency('pry-byebug')
38
+ end
@@ -0,0 +1,14 @@
1
+ require 'active_job'
2
+ require 'active_support'
3
+
4
+ require 'active_job/retry'
5
+ require 'active_job/retry/exponential_backoff'
6
+ require 'active_job/retry/invalid_configuration_error'
7
+ require 'active_job/retry/options_validator'
8
+ require 'active_job/retry/exponential_options_validator'
9
+
10
+ require 'active_job/retry/version'
11
+
12
+ unless ActiveJob::Base.method_defined?(:deserialize)
13
+ require 'active_job/retry/deserialize_monkey_patch'
14
+ end
@@ -0,0 +1,130 @@
1
+ module ActiveJob
2
+ module Retry
3
+ class UnsupportedAdapterError < StandardError; end
4
+ SUPPORTED_ADAPTERS = %i(backburner delayed_job que resque sidekiq).freeze
5
+
6
+ # If you want your job to retry on failure, simply include this module in your class,
7
+ #
8
+ # class DeliverWebHook < ActiveJob::Base
9
+ # include ActiveJob::Retry
10
+ # queue_as :web_hooks
11
+ #
12
+ # retry_with limit: 8, # default 1
13
+ # delay: 60, # default 0
14
+ # fatal_exceptions: [RuntimeError], # default [], i.e. none
15
+ # retry_exceptions: [TimeoutError] # default nil, i.e. all
16
+ #
17
+ # def perform(url, web_hook_id, hmac_key)
18
+ # work!
19
+ # end
20
+ # end
21
+ def self.included(base)
22
+ # This breaks all specs because the adapter gets set after class eval :(
23
+ # unless SUPPORTED_ADAPTERS.include?(ActiveJob::Base.queue_adapter)
24
+ # raise UnsupportedAdapterError,
25
+ # "Only Backburner, DelayedJob, Que, Resque, and Sidekiq support delayed " \
26
+ # "retries. #{ActiveJob::Base.queue_adapter} is not supported."
27
+ # end
28
+
29
+ base.extend(ClassMethods)
30
+ end
31
+
32
+ module ClassMethods
33
+ # Setup DSL
34
+ def retry_with(options)
35
+ OptionsValidator.new(options).validate!
36
+
37
+ @retry_limit = options[:limit] if options[:limit]
38
+ @retry_delay = options[:delay] if options[:delay]
39
+ @fatal_exceptions = options[:fatal_exceptions] if options[:fatal_exceptions]
40
+ @retry_exceptions = options[:retry_exceptions] if options[:retry_exceptions]
41
+ end
42
+
43
+ ############
44
+ # Defaults #
45
+ ############
46
+ def retry_limit
47
+ @retry_limit ||= 1
48
+ end
49
+
50
+ def retry_delay
51
+ @retry_delay ||= 0
52
+ end
53
+
54
+ def fatal_exceptions
55
+ @fatal_exceptions ||= []
56
+ end
57
+
58
+ def retry_exceptions
59
+ @retry_exceptions ||= nil
60
+ end
61
+
62
+ #################
63
+ # Retry helpers #
64
+ #################
65
+ def retry_exception?(exception)
66
+ return true if retry_exceptions.nil? && fatal_exceptions.empty?
67
+ return exception_whitelisted?(exception) unless retry_exceptions.nil?
68
+ !exception_blacklisted?(exception)
69
+ end
70
+
71
+ def exception_whitelisted?(exception)
72
+ retry_exceptions.any? { |ex| exception.is_a?(ex) }
73
+ end
74
+
75
+ def exception_blacklisted?(exception)
76
+ fatal_exceptions.any? { |ex| exception.is_a?(ex) }
77
+ end
78
+ end
79
+
80
+ #############
81
+ # Overrides #
82
+ #############
83
+ def serialize
84
+ super.merge('retry_attempt' => retry_attempt + 1)
85
+ end
86
+
87
+ def deserialize(job_data)
88
+ super(job_data)
89
+ @retry_attempt = job_data['retry_attempt']
90
+ end
91
+
92
+ # Override `rescue_with_handler` to make sure our catch is the last one, and doesn't
93
+ # happen if the exception has already been caught in a `rescue_from`
94
+ def rescue_with_handler(exception)
95
+ super || retry_or_reraise(exception)
96
+ end
97
+
98
+ ##################
99
+ # Retrying logic #
100
+ ##################
101
+ def retry_attempt
102
+ @retry_attempt ||= 0
103
+ end
104
+
105
+ # Override me if you want more complex behaviour
106
+ def retry_delay
107
+ self.class.retry_delay
108
+ end
109
+
110
+ def should_retry?(exception)
111
+ return false if retry_limit_reached?
112
+ return false unless self.class.retry_exception?(exception)
113
+ true
114
+ end
115
+
116
+ def retry_limit_reached?
117
+ return true if self.class.retry_limit == 0
118
+ return false if self.class.retry_limit == -1
119
+ retry_attempt >= self.class.retry_limit
120
+ end
121
+
122
+ def retry_or_reraise(exception)
123
+ raise exception unless should_retry?(exception)
124
+
125
+ this_delay = retry_delay
126
+ logger.log(Logger::INFO, "Retrying (attempt #{retry_attempt + 1}, waiting #{this_delay}s)")
127
+ retry_job(wait: this_delay)
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,22 @@
1
+ # In Rails 4.2, ActiveJob externally applies deserialized job ID, queue name, arguments to
2
+ # the instantiated job in `ActiveJob::Base.deserialize`, which cannot be overridden in
3
+ # subclasses. https://github.com/rails/rails/pull/18260 changes this to delegate as much
4
+ # of the deserialization as possible to the instance, i.e. `ActiveJob::Base#deserialize`,
5
+ # which can be overridden. This allows us to store extra information in the queue (i.e.
6
+ # retry_attempt), which is essential for ActiveJob::Retry.
7
+
8
+ raise "Unnecessary monkey patch!" if ActiveJob::Base.method_defined?(:deserialize)
9
+
10
+ class ActiveJob::Base
11
+ def self.deserialize(job_data)
12
+ job = job_data['job_class'].constantize.new
13
+ job.deserialize(job_data)
14
+ job
15
+ end
16
+
17
+ def deserialize(job_data)
18
+ self.job_id = job_data['job_id']
19
+ self.queue_name = job_data['queue_name']
20
+ self.serialized_arguments = job_data['arguments']
21
+ end
22
+ end
@@ -0,0 +1,92 @@
1
+ module ActiveJob
2
+ module Retry
3
+ # If you want your job to retry on failure using a varying delay, simply
4
+ # extend your class with this module:
5
+ #
6
+ # class DeliverSMS < ActiveJob::Base
7
+ # extend ActiveJob::Retry::ExponentialBackoff
8
+ # queue_as :messages
9
+ #
10
+ # def perform(mobile_number, message)
11
+ # SmsService.deliver!(mobile_number, message)
12
+ # end
13
+ # end
14
+ #
15
+ # Easily do something custom:
16
+ #
17
+ # class DeliverSMS
18
+ # extend ActiveJob::Retry::ExponentialBackoff
19
+ # queue_as :messages
20
+ #
21
+ # # Options from ActiveJob::Retry can also be used
22
+ # retry_with strategy: [0, 60], # retry immediately, then after 60 seconds
23
+ # min_delay_multiplier: 0.8, # multiply the delay by a random number
24
+ # max_delay_multiplier: 1.5 # between 0.8 and 1.5
25
+ #
26
+ # def perform(mobile_number, message)
27
+ # SmsService.deliver!(mobile_number, message)
28
+ # end
29
+ # end
30
+ #
31
+ module ExponentialBackoff
32
+ include ActiveJob::Retry
33
+
34
+ def self.included(base)
35
+ base.extend(ClassMethods)
36
+ end
37
+
38
+ module ClassMethods
39
+ include ActiveJob::Retry::ClassMethods
40
+
41
+ # Setup DSL
42
+ def backoff_with(options)
43
+ retry_with(options)
44
+ ExponentialOptionsValidator.new(options).validate!
45
+
46
+ @retry_limit = options[:limit] || options[:strategy].length if options[:strategy]
47
+ @backoff_strategy = options[:strategy] if options[:strategy]
48
+ @min_delay_multiplier = options[:min_delay_multiplier] if options[:min_delay_multiplier]
49
+ @max_delay_multiplier = options[:max_delay_multiplier] if options[:max_delay_multiplier]
50
+ end
51
+
52
+ ############
53
+ # Defaults #
54
+ ############
55
+ def retry_limit
56
+ @retry_limit ||= (backoff_strategy || []).length
57
+ end
58
+
59
+ def backoff_strategy
60
+ @backoff_strategy ||= nil
61
+ end
62
+
63
+ def min_delay_multiplier
64
+ @min_delay_multiplier ||= 1.0
65
+ end
66
+
67
+ def max_delay_multiplier
68
+ @max_delay_multiplier ||= 1.0
69
+ end
70
+ end
71
+
72
+ ###############
73
+ # Retry logic #
74
+ ###############
75
+ def retry_delay
76
+ delay = self.class.backoff_strategy[retry_attempt]
77
+
78
+ return (delay * self.class.max_delay_multiplier).to_i unless random_delay?
79
+
80
+ (delay * rand(random_delay_range)).to_i
81
+ end
82
+
83
+ def random_delay?
84
+ self.class.min_delay_multiplier != self.class.max_delay_multiplier
85
+ end
86
+
87
+ def random_delay_range
88
+ self.class.min_delay_multiplier..self.class.max_delay_multiplier
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,57 @@
1
+ module ActiveJob
2
+ module Retry
3
+ class ExponentialOptionsValidator
4
+ DELAY_MULTIPLIER_KEYS = %i(min_delay_multiplier max_delay_multiplier).freeze
5
+
6
+ def initialize(options)
7
+ @options = options
8
+ @retry_limit = options[:limit] || options.fetch(:strategy, []).length
9
+ end
10
+
11
+ def validate!
12
+ validate_strategy!
13
+ validate_delay_multipliers!
14
+ end
15
+
16
+ private
17
+
18
+ attr_reader :options, :retry_limit
19
+
20
+ def validate_strategy!
21
+ unless options[:strategy]
22
+ raise InvalidConfigurationError, "You must define a backoff strategy"
23
+ end
24
+
25
+ return unless retry_limit
26
+
27
+ unless retry_limit > 0
28
+ raise InvalidConfigurationError,
29
+ "Exponential backoff cannot be used with infinite or no retries"
30
+ end
31
+
32
+ return if options[:strategy].length == retry_limit
33
+
34
+ raise InvalidConfigurationError, "Strategy must have a delay for each retry"
35
+ end
36
+
37
+ def validate_delay_multipliers!
38
+ unless both_or_neither_multiplier_supplied?
39
+ raise InvalidConfigurationError,
40
+ "If one of min/max_delay_multiplier is supplied, both are required"
41
+ end
42
+
43
+ return unless options[:min_delay_multiplier] && options[:max_delay_multiplier]
44
+
45
+ return if options[:min_delay_multiplier] <= options[:max_delay_multiplier]
46
+
47
+ raise InvalidConfigurationError,
48
+ "min_delay_multiplier must be less than or equal to max_delay_multiplier"
49
+ end
50
+
51
+ def both_or_neither_multiplier_supplied?
52
+ supplied = DELAY_MULTIPLIER_KEYS.map { |key| options.key?(key) }
53
+ supplied.none? || supplied.all?
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,6 @@
1
+ module ActiveJob
2
+ module Retry
3
+ class InvalidConfigurationError < StandardError
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,84 @@
1
+ module ActiveJob
2
+ module Retry
3
+ class OptionsValidator
4
+ def initialize(options)
5
+ @options = options
6
+ end
7
+
8
+ def validate!
9
+ validate_limit!
10
+ validate_delay!
11
+ validate_fatal_exceptions!
12
+ validate_retry_exceptions!
13
+ end
14
+
15
+ private
16
+
17
+ attr_reader :options
18
+
19
+ # Limit must be an integer >= -1
20
+ # If it is -1 you *must* set `infinite_job: true` and understand that you're
21
+ # entering a world of pain and your ops team might hurt you.
22
+ def validate_limit!
23
+ return unless options[:limit]
24
+
25
+ unless options[:limit].is_a?(Fixnum)
26
+ raise InvalidConfigurationError, "Limit must be an integer"
27
+ end
28
+
29
+ raise InvalidConfigurationError, "Limit must be >= -1" if options[:limit] < -1
30
+
31
+ if options[:limit] == -1 && !options[:infinite_job]
32
+ raise InvalidConfigurationError,
33
+ "You must set `infinite_job: true` to use an infinite job"
34
+ end
35
+ end
36
+
37
+ # Delay must be non-negative
38
+ def validate_delay!
39
+ return unless options[:delay]
40
+
41
+ unless options[:delay] >= 0
42
+ raise InvalidConfigurationError, "Delay must be non-negative"
43
+ end
44
+ end
45
+
46
+ # Fatal exceptions must be an array (cannot be nil, since then all exceptions would
47
+ # be fatal - for that just set `limit: 0`)
48
+ def validate_fatal_exceptions!
49
+ return unless options[:fatal_exceptions]
50
+
51
+ unless options[:retry_exceptions].nil?
52
+ raise InvalidConfigurationError,
53
+ "fatal_exceptions and retry_exceptions cannot be used together"
54
+ end
55
+
56
+ unless options[:fatal_exceptions].is_a?(Array)
57
+ raise InvalidConfigurationError, "fatal_exceptions must be an array"
58
+ end
59
+
60
+ unless options[:fatal_exceptions].all? { |ex| ex.is_a?(Class) && ex <= Exception }
61
+ raise InvalidConfigurationError, "fatal_exceptions must be exceptions!"
62
+ end
63
+ end
64
+
65
+ # Retry exceptions must be an array of exceptions or `nil` to retry any exception
66
+ def validate_retry_exceptions!
67
+ return unless options[:retry_exceptions]
68
+
69
+ unless options[:fatal_exceptions].nil?
70
+ raise InvalidConfigurationError,
71
+ "retry_exceptions and fatal_exceptions cannot be used together"
72
+ end
73
+
74
+ unless options[:retry_exceptions].is_a?(Array)
75
+ raise InvalidConfigurationError, "retry_exceptions must be an array or nil"
76
+ end
77
+
78
+ unless options[:retry_exceptions].all? { |ex| ex.is_a?(Class) && ex <= Exception }
79
+ raise InvalidConfigurationError, "retry_exceptions must be exceptions!"
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,5 @@
1
+ module ActiveJob
2
+ module Retry
3
+ VERSION = "0.0.1"
4
+ end
5
+ end
@@ -0,0 +1,228 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe ActiveJob::Retry do
4
+ subject(:job) { Class.new(ActiveJob::Base) { include ActiveJob::Retry } }
5
+
6
+ describe '.retry_with' do
7
+ context 'valid options' do
8
+ before { job.retry_with(options) }
9
+
10
+ context 'limit only' do
11
+ context 'infinite retries' do
12
+ let(:options) { { limit: -1, infinite_job: true } }
13
+ its(:retry_limit) { is_expected.to eq(-1) }
14
+ end
15
+
16
+ context 'no retries' do
17
+ let(:options) { { limit: 0 } }
18
+ its(:retry_limit) { is_expected.to eq(0) }
19
+ end
20
+
21
+ context 'some retries' do
22
+ let(:options) { { limit: 3 } }
23
+ its(:retry_limit) { is_expected.to eq(3) }
24
+ end
25
+ end
26
+
27
+ context 'delay only' do
28
+ let(:options) { { delay: 0 } }
29
+ its(:retry_delay) { is_expected.to eq(0) }
30
+ end
31
+
32
+ context 'fatal exceptions' do
33
+ let(:options) { { fatal_exceptions: [RuntimeError] } }
34
+ its(:fatal_exceptions) { is_expected.to eq([RuntimeError]) }
35
+ end
36
+
37
+ context 'retry exceptions' do
38
+ let(:options) { { retry_exceptions: [StandardError, NoMethodError] } }
39
+ its(:retry_exceptions) { is_expected.to eq([StandardError, NoMethodError]) }
40
+ end
41
+
42
+ context 'multiple options' do
43
+ let(:options) { { limit: 3, delay: 10, retry_exceptions: [RuntimeError] } }
44
+
45
+ its(:retry_limit) { is_expected.to eq(3) }
46
+ its(:retry_delay) { is_expected.to eq(10) }
47
+ its(:retry_exceptions) { is_expected.to eq([RuntimeError]) }
48
+ end
49
+ end
50
+
51
+ context 'invalid options' do
52
+ subject { -> { job.retry_with(options) } }
53
+
54
+ context 'bad limit' do
55
+ let(:options) { { limit: -2 } }
56
+ it { is_expected.to raise_error(ActiveJob::Retry::InvalidConfigurationError) }
57
+ end
58
+
59
+ context 'accidental infinite limit' do
60
+ let(:options) { { limit: -1 } }
61
+ it { is_expected.to raise_error(ActiveJob::Retry::InvalidConfigurationError) }
62
+ end
63
+
64
+ context 'bad delay' do
65
+ let(:options) { { delay: -1 } }
66
+ it { is_expected.to raise_error(ActiveJob::Retry::InvalidConfigurationError) }
67
+ end
68
+
69
+ context 'bad fatal exceptions' do
70
+ let(:options) { { fatal_exceptions: ['StandardError'] } }
71
+ it { is_expected.to raise_error(ActiveJob::Retry::InvalidConfigurationError) }
72
+ end
73
+
74
+ context 'bad retry exceptions' do
75
+ let(:options) { { retry_exceptions: [:runtime] } }
76
+ it { is_expected.to raise_error(ActiveJob::Retry::InvalidConfigurationError) }
77
+ end
78
+
79
+ context 'retry and fatal exceptions together' do
80
+ let(:options) { { fatal_exceptions: [StandardError], retry_exceptions: [] } }
81
+ it { is_expected.to raise_error(ActiveJob::Retry::InvalidConfigurationError) }
82
+ end
83
+ end
84
+ end
85
+
86
+ describe '.retry_exception?' do
87
+ subject { job.retry_exception?(exception) }
88
+
89
+ context 'defaults (retry everything)' do
90
+ context 'Exception' do
91
+ let(:exception) { Exception.new }
92
+ it { is_expected.to be_truthy }
93
+ end
94
+
95
+ context 'RuntimeError' do
96
+ let(:exception) { RuntimeError.new }
97
+ it { is_expected.to be_truthy }
98
+ end
99
+
100
+ context 'InvalidConfigurationError' do
101
+ let(:exception) { ActiveJob::Retry::InvalidConfigurationError.new }
102
+ it { is_expected.to be_truthy }
103
+ end
104
+ end
105
+
106
+ context 'with whitelist' do
107
+ before { job.retry_with(retry_exceptions: [RuntimeError]) }
108
+
109
+ context 'Exception' do
110
+ let(:exception) { Exception.new }
111
+ it { is_expected.to be_falsey }
112
+ end
113
+
114
+ context 'RuntimeError' do
115
+ let(:exception) { RuntimeError.new }
116
+ it { is_expected.to be_truthy }
117
+ end
118
+
119
+ context 'subclass of RuntimeError' do
120
+ let(:exception) { Class.new(RuntimeError).new }
121
+ it { is_expected.to be_truthy }
122
+ end
123
+ end
124
+
125
+ context 'with blacklist' do
126
+ before { job.retry_with(fatal_exceptions: [RuntimeError]) }
127
+
128
+ context 'Exception' do
129
+ let(:exception) { Exception.new }
130
+ it { is_expected.to be_truthy }
131
+ end
132
+
133
+ context 'RuntimeError' do
134
+ let(:exception) { RuntimeError.new }
135
+ it { is_expected.to be_falsey }
136
+ end
137
+
138
+ context 'subclass of RuntimeError' do
139
+ let(:exception) { Class.new(RuntimeError).new }
140
+ it { is_expected.to be_falsey }
141
+ end
142
+ end
143
+ end
144
+
145
+ describe '#retry_limit_reached?' do
146
+ let(:instance) { job.tap { |job| job.retry_with(options) }.new }
147
+ subject { instance.retry_limit_reached? }
148
+
149
+ context 'when the limit is infinite' do
150
+ let(:options) { { limit: -1, infinite_job: true } }
151
+
152
+ context 'first attempt' do
153
+ before { instance.instance_variable_set(:@retry_attempt, 1) }
154
+ it { is_expected.to be_falsey }
155
+ end
156
+
157
+ context '99999th attempt' do
158
+ before { instance.instance_variable_set(:@retry_attempt, 99999) }
159
+ it { is_expected.to be_falsey }
160
+ end
161
+ end
162
+
163
+ context 'when the limit is 0' do
164
+ let(:options) { { limit: 0 } }
165
+
166
+ context 'first attempt' do
167
+ before { instance.instance_variable_set(:@retry_attempt, 1) }
168
+ it { is_expected.to be_truthy }
169
+ end
170
+
171
+ context '99999th attempt' do
172
+ before { instance.instance_variable_set(:@retry_attempt, 99999) }
173
+ it { is_expected.to be_truthy }
174
+ end
175
+ end
176
+
177
+ context 'when the limit is 5' do
178
+ let(:options) { { limit: 5 } }
179
+
180
+ context 'first attempt' do
181
+ before { instance.instance_variable_set(:@retry_attempt, 1) }
182
+ it { is_expected.to be_falsey }
183
+ end
184
+
185
+ context '4th attempt' do
186
+ before { instance.instance_variable_set(:@retry_attempt, 4) }
187
+ it { is_expected.to be_falsey }
188
+ end
189
+
190
+ context '5th attempt' do
191
+ before { instance.instance_variable_set(:@retry_attempt, 5) }
192
+ it { is_expected.to be_truthy }
193
+ end
194
+ end
195
+ end
196
+
197
+ describe '#retry_or_reraise' do
198
+ let(:instance) { job.new }
199
+ let(:exception) { RuntimeError.new }
200
+ subject(:retry_or_reraise) { instance.retry_or_reraise(exception) }
201
+
202
+ context 'when we should not retry' do
203
+ before do
204
+ allow(instance).to receive(:should_retry?).with(exception).and_return(false)
205
+ end
206
+
207
+ specify { expect { retry_or_reraise }.to raise_error(exception) }
208
+ end
209
+
210
+ context 'when we should retry' do
211
+ before do
212
+ allow(instance).to receive(:should_retry?).with(exception).and_return(true)
213
+ allow(instance).to receive(:retry_job).and_return(true)
214
+ end
215
+
216
+ it 'logs the retry' do
217
+ expect(ActiveJob::Base.logger).to receive(:log).
218
+ with(Logger::INFO, 'Retrying (attempt 1)')
219
+ retry_or_reraise
220
+ end
221
+
222
+ it 'retries the job' do
223
+ expect(instance).to receive(:retry_job).with(wait: 0)
224
+ retry_or_reraise
225
+ end
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,23 @@
1
+ require 'active_job-retry'
2
+ require 'rspec/its'
3
+ require 'pry'
4
+
5
+ RSpec.configure do |config|
6
+ config.expect_with :rspec do |expectations|
7
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
8
+ end
9
+
10
+ config.mock_with :rspec do |mocks|
11
+ mocks.verify_partial_doubles = true
12
+ end
13
+
14
+ config.disable_monkey_patching!
15
+
16
+ config.warnings = true
17
+
18
+ # config.profile_examples = 10
19
+
20
+ config.order = :random
21
+
22
+ Kernel.srand config.seed
23
+ end
metadata ADDED
@@ -0,0 +1,158 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activejob-retry
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Isaac Seymour
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-01-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activejob
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - '>='
18
+ - !ruby/object:Gem::Version
19
+ version: '4.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - '>='
25
+ - !ruby/object:Gem::Version
26
+ version: '4.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - '>='
32
+ - !ruby/object:Gem::Version
33
+ version: '4.2'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - '>='
39
+ - !ruby/object:Gem::Version
40
+ version: '4.2'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - '>='
46
+ - !ruby/object:Gem::Version
47
+ version: '10.3'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - '>='
53
+ - !ruby/object:Gem::Version
54
+ version: '10.3'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - '>='
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec-its
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - '>='
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - '>='
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: pry-byebug
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - '>='
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - '>='
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ description: |2
98
+ activejob-retry provides a simple DSL for automatically retrying ActiveJobs when they
99
+ fail, with exponential backoff.
100
+
101
+ Features:
102
+
103
+ * (Should) work with any queue adapter that supports retries.
104
+ * Whitelist/blacklist exceptions to retry on.
105
+ * Exponential backoff (varying the delay between retries).
106
+ * Light and easy to override retry logic.
107
+ email:
108
+ - isaac@isaacseymour.co.uk
109
+ executables: []
110
+ extensions: []
111
+ extra_rdoc_files: []
112
+ files:
113
+ - .gitignore
114
+ - .rspec
115
+ - .travis.yml
116
+ - CHANGELOG.md
117
+ - Gemfile
118
+ - Gemfile.lock
119
+ - LICENSE
120
+ - README.md
121
+ - Rakefile
122
+ - activejob-retry.gemspec
123
+ - lib/active_job-retry.rb
124
+ - lib/active_job/retry.rb
125
+ - lib/active_job/retry/deserialize_monkey_patch.rb
126
+ - lib/active_job/retry/exponential_backoff.rb
127
+ - lib/active_job/retry/exponential_options_validator.rb
128
+ - lib/active_job/retry/invalid_configuration_error.rb
129
+ - lib/active_job/retry/options_validator.rb
130
+ - lib/active_job/retry/version.rb
131
+ - spec/retry_spec.rb
132
+ - spec/spec_helper.rb
133
+ homepage: http://github.com/isaacseymour/activejob-retry
134
+ licenses:
135
+ - MIT
136
+ metadata: {}
137
+ post_install_message:
138
+ rdoc_options: []
139
+ require_paths:
140
+ - lib
141
+ required_ruby_version: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - '>='
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
146
+ required_rubygems_version: !ruby/object:Gem::Requirement
147
+ requirements:
148
+ - - '>='
149
+ - !ruby/object:Gem::Version
150
+ version: '0'
151
+ requirements: []
152
+ rubyforge_project:
153
+ rubygems_version: 2.4.1
154
+ signing_key:
155
+ specification_version: 4
156
+ summary: Automatic retrying DSL for ActiveJob
157
+ test_files: []
158
+ has_rdoc: false