activejob-retry 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +22 -0
- data/.travis.yml +20 -5
- data/CHANGELOG.md +3 -0
- data/Gemfile +9 -6
- data/Gemfile.lock +63 -51
- data/LICENSE +1 -1
- data/README.md +57 -25
- data/Rakefile +14 -13
- data/activejob-retry.gemspec +9 -12
- data/lib/active_job/retry.rb +69 -90
- data/lib/active_job/retry/constant_backoff_strategy.rb +50 -0
- data/lib/active_job/retry/constant_options_validator.rb +76 -0
- data/lib/active_job/retry/deserialize_monkey_patch.rb +17 -12
- data/lib/active_job/retry/errors.rb +6 -0
- data/lib/active_job/retry/variable_backoff_strategy.rb +34 -0
- data/lib/active_job/retry/variable_options_validator.rb +56 -0
- data/lib/active_job/retry/version.rb +1 -1
- data/spec/retry/constant_backoff_strategy_spec.rb +115 -0
- data/spec/retry/constant_options_validator_spec.rb +81 -0
- data/spec/retry/variable_backoff_strategy_spec.rb +121 -0
- data/spec/retry/variable_options_validator_spec.rb +83 -0
- data/spec/retry_spec.rb +114 -170
- data/spec/spec_helper.rb +3 -2
- data/test/adapters/backburner.rb +3 -0
- data/test/adapters/delayed_job.rb +7 -0
- data/test/adapters/inline.rb +1 -0
- data/test/adapters/qu.rb +3 -0
- data/test/adapters/que.rb +4 -0
- data/test/adapters/queue_classic.rb +2 -0
- data/test/adapters/resque.rb +2 -0
- data/test/adapters/sidekiq.rb +2 -0
- data/test/adapters/sneakers.rb +2 -0
- data/test/adapters/sucker_punch.rb +2 -0
- data/test/cases/adapter_test.rb +8 -0
- data/test/cases/argument_serialization_test.rb +84 -0
- data/test/cases/callbacks_test.rb +23 -0
- data/test/cases/job_serialization_test.rb +15 -0
- data/test/cases/logging_test.rb +114 -0
- data/test/cases/queue_naming_test.rb +102 -0
- data/test/cases/queuing_test.rb +44 -0
- data/test/cases/rescue_test.rb +35 -0
- data/test/cases/test_case_test.rb +14 -0
- data/test/cases/test_helper_test.rb +226 -0
- data/test/helper.rb +21 -0
- data/test/integration/queuing_test.rb +46 -0
- data/test/jobs/callback_job.rb +31 -0
- data/test/jobs/gid_job.rb +10 -0
- data/test/jobs/hello_job.rb +9 -0
- data/test/jobs/logging_job.rb +12 -0
- data/test/jobs/nested_job.rb +12 -0
- data/test/jobs/rescue_job.rb +31 -0
- data/test/models/person.rb +20 -0
- data/test/support/backburner/inline.rb +8 -0
- data/test/support/delayed_job/delayed/backend/test.rb +111 -0
- data/test/support/delayed_job/delayed/serialization/test.rb +0 -0
- data/test/support/integration/adapters/backburner.rb +38 -0
- data/test/support/integration/adapters/delayed_job.rb +20 -0
- data/test/support/integration/adapters/que.rb +37 -0
- data/test/support/integration/adapters/resque.rb +49 -0
- data/test/support/integration/adapters/sidekiq.rb +58 -0
- data/test/support/integration/dummy_app_template.rb +28 -0
- data/test/support/integration/helper.rb +30 -0
- data/test/support/integration/jobs_manager.rb +27 -0
- data/test/support/integration/test_case_helpers.rb +48 -0
- data/test/support/job_buffer.rb +19 -0
- data/test/support/que/inline.rb +9 -0
- metadata +60 -12
- data/lib/active_job-retry.rb +0 -14
- data/lib/active_job/retry/exponential_backoff.rb +0 -92
- data/lib/active_job/retry/exponential_options_validator.rb +0 -57
- data/lib/active_job/retry/invalid_configuration_error.rb +0 -6
- data/lib/active_job/retry/options_validator.rb +0 -84
data/activejob-retry.gemspec
CHANGED
@@ -1,7 +1,4 @@
|
|
1
|
-
|
2
|
-
lib = File.expand_path('../lib', __FILE__)
|
3
|
-
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
-
require 'active_job/retry/version'
|
1
|
+
require File.expand_path('../lib/active_job/retry/version', __FILE__)
|
5
2
|
|
6
3
|
Gem::Specification.new do |s|
|
7
4
|
s.name = 'activejob-retry'
|
@@ -9,24 +6,24 @@ Gem::Specification.new do |s|
|
|
9
6
|
s.date = Date.today.strftime('%Y-%m-%d')
|
10
7
|
s.authors = ['Isaac Seymour']
|
11
8
|
s.email = ['isaac@isaacseymour.co.uk']
|
12
|
-
s.summary = 'Automatic
|
9
|
+
s.summary = 'Automatic retry functionality for ActiveJob.'
|
13
10
|
s.description = <<-EOL
|
14
|
-
activejob-retry provides
|
15
|
-
|
11
|
+
activejob-retry provides automatic retry functionality for failed
|
12
|
+
ActiveJobs, with exponential backoff.
|
16
13
|
|
17
14
|
Features:
|
18
15
|
|
19
|
-
*
|
16
|
+
* Works with any queue adapter that supports retries.
|
20
17
|
* Whitelist/blacklist exceptions to retry on.
|
21
18
|
* Exponential backoff (varying the delay between retries).
|
22
19
|
* Light and easy to override retry logic.
|
23
20
|
EOL
|
24
|
-
s.homepage = 'http://github.com/
|
21
|
+
s.homepage = 'http://github.com/gocardless/activejob-retry'
|
25
22
|
s.license = 'MIT'
|
26
23
|
|
27
24
|
s.has_rdoc = false
|
28
|
-
s.files = `git ls-files`.split(
|
29
|
-
s.require_paths = %w
|
25
|
+
s.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR)
|
26
|
+
s.require_paths = %w(lib)
|
30
27
|
|
31
28
|
s.add_dependency('activejob', '>= 4.2')
|
32
29
|
s.add_dependency('activesupport', '>= 4.2')
|
@@ -34,5 +31,5 @@ Gem::Specification.new do |s|
|
|
34
31
|
s.add_development_dependency('rake', ' >= 10.3')
|
35
32
|
s.add_development_dependency('rspec')
|
36
33
|
s.add_development_dependency('rspec-its')
|
37
|
-
s.add_development_dependency('
|
34
|
+
s.add_development_dependency('rubocop')
|
38
35
|
end
|
data/lib/active_job/retry.rb
CHANGED
@@ -1,87 +1,73 @@
|
|
1
|
+
require 'active_job'
|
2
|
+
require 'active_support'
|
3
|
+
require 'active_support/core_ext' # ActiveJob uses core exts, but doesn't require it
|
4
|
+
require 'active_job/retry/version'
|
5
|
+
require 'active_job/retry/errors'
|
6
|
+
require 'active_job/retry/constant_backoff_strategy'
|
7
|
+
require 'active_job/retry/variable_backoff_strategy'
|
8
|
+
|
9
|
+
unless ActiveJob::Base.method_defined?(:deserialize)
|
10
|
+
require 'active_job/retry/deserialize_monkey_patch'
|
11
|
+
end
|
12
|
+
|
1
13
|
module ActiveJob
|
2
14
|
module Retry
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
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
|
15
|
+
SUPPORTED_ADAPTERS = [
|
16
|
+
'ActiveJob::QueueAdapters::BackburnerAdapter',
|
17
|
+
'ActiveJob::QueueAdapters::DelayedJobAdapter',
|
18
|
+
'ActiveJob::QueueAdapters::ResqueAdapter',
|
19
|
+
'ActiveJob::QueueAdapters::QueAdapter'
|
20
|
+
].freeze
|
21
|
+
|
21
22
|
def self.included(base)
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
# end
|
23
|
+
unless SUPPORTED_ADAPTERS.include?(ActiveJob::Base.queue_adapter.to_s)
|
24
|
+
raise UnsupportedAdapterError,
|
25
|
+
'Only Backburner, DelayedJob, Que, and Resque support delayed ' \
|
26
|
+
"retries. #{ActiveJob::Base.queue_adapter} is not supported."
|
27
|
+
end
|
28
28
|
|
29
29
|
base.extend(ClassMethods)
|
30
30
|
end
|
31
31
|
|
32
|
-
|
33
|
-
|
34
|
-
|
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
|
32
|
+
#################
|
33
|
+
# Configuration #
|
34
|
+
#################
|
42
35
|
|
43
|
-
|
44
|
-
|
45
|
-
############
|
46
|
-
def retry_limit
|
47
|
-
@retry_limit ||= 1
|
48
|
-
end
|
49
|
-
|
50
|
-
def retry_delay
|
51
|
-
@retry_delay ||= 0
|
52
|
-
end
|
36
|
+
module ClassMethods
|
37
|
+
attr_reader :backoff_strategy
|
53
38
|
|
54
|
-
def
|
55
|
-
|
39
|
+
def constant_retry(options)
|
40
|
+
retry_with(ConstantBackoffStrategy.new(options))
|
56
41
|
end
|
57
42
|
|
58
|
-
def
|
59
|
-
|
43
|
+
def variable_retry(options)
|
44
|
+
retry_with(VariableBackoffStrategy.new(options))
|
60
45
|
end
|
61
46
|
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
!exception_blacklisted?(exception)
|
69
|
-
end
|
47
|
+
def retry_with(backoff_strategy)
|
48
|
+
unless backoff_strategy_valid?(backoff_strategy)
|
49
|
+
raise InvalidConfigurationError,
|
50
|
+
'Backoff strategies must define `should_retry?(attempt, exception)`, ' \
|
51
|
+
'and `retry_delay(attempt, exception)`.'
|
52
|
+
end
|
70
53
|
|
71
|
-
|
72
|
-
retry_exceptions.any? { |ex| exception.is_a?(ex) }
|
54
|
+
@backoff_strategy = backoff_strategy
|
73
55
|
end
|
74
56
|
|
75
|
-
def
|
76
|
-
|
57
|
+
def backoff_strategy_valid?(backoff_strategy)
|
58
|
+
backoff_strategy.respond_to?(:should_retry?) &&
|
59
|
+
backoff_strategy.respond_to?(:retry_delay) &&
|
60
|
+
backoff_strategy.method(:should_retry?).arity == 2 &&
|
61
|
+
backoff_strategy.method(:retry_delay).arity == 2
|
77
62
|
end
|
78
63
|
end
|
79
64
|
|
80
|
-
|
81
|
-
#
|
82
|
-
|
65
|
+
#############################
|
66
|
+
# Storage of attempt number #
|
67
|
+
#############################
|
68
|
+
|
83
69
|
def serialize
|
84
|
-
super.merge('retry_attempt' => retry_attempt
|
70
|
+
super.merge('retry_attempt' => retry_attempt)
|
85
71
|
end
|
86
72
|
|
87
73
|
def deserialize(job_data)
|
@@ -89,42 +75,35 @@ module ActiveJob
|
|
89
75
|
@retry_attempt = job_data['retry_attempt']
|
90
76
|
end
|
91
77
|
|
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
78
|
def retry_attempt
|
102
|
-
@retry_attempt ||=
|
79
|
+
@retry_attempt ||= 1
|
103
80
|
end
|
104
81
|
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
end
|
82
|
+
##########################
|
83
|
+
# Performing the retries #
|
84
|
+
##########################
|
109
85
|
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
86
|
+
# Override `rescue_with_handler` to make sure our catch is the last one, and doesn't
|
87
|
+
# happen if the exception has already been caught in a `rescue_from`
|
88
|
+
def rescue_with_handler(exception)
|
89
|
+
super || retry_or_reraise(exception)
|
114
90
|
end
|
115
91
|
|
116
|
-
|
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
|
92
|
+
private
|
121
93
|
|
122
94
|
def retry_or_reraise(exception)
|
123
|
-
|
95
|
+
unless self.class.backoff_strategy.should_retry?(retry_attempt, exception)
|
96
|
+
raise exception
|
97
|
+
end
|
124
98
|
|
125
|
-
this_delay = retry_delay
|
126
|
-
|
99
|
+
this_delay = self.class.backoff_strategy.retry_delay(retry_attempt, exception)
|
100
|
+
# TODO: This breaks DelayedJob and Resque for some weird ActiveSupport reason.
|
101
|
+
# logger.log(Logger::INFO,
|
102
|
+
# "Retrying (attempt #{retry_attempt + 1}, waiting #{this_delay}s)")
|
103
|
+
@retry_attempt += 1
|
127
104
|
retry_job(wait: this_delay)
|
105
|
+
|
106
|
+
true # Exception has been handled
|
128
107
|
end
|
129
108
|
end
|
130
109
|
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
require 'active_job/retry/constant_options_validator'
|
2
|
+
|
3
|
+
module ActiveJob
|
4
|
+
module Retry
|
5
|
+
class ConstantBackoffStrategy
|
6
|
+
def initialize(options)
|
7
|
+
ConstantOptionsValidator.new(options).validate!
|
8
|
+
@retry_limit = options.fetch(:limit, 1)
|
9
|
+
@retry_delay = options.fetch(:delay, 0)
|
10
|
+
@fatal_exceptions = options.fetch(:fatal_exceptions, [])
|
11
|
+
@retry_exceptions = options.fetch(:retry_exceptions, nil)
|
12
|
+
end
|
13
|
+
|
14
|
+
def should_retry?(attempt, exception)
|
15
|
+
return false if retry_limit_reached?(attempt)
|
16
|
+
return false unless retry_exception?(exception)
|
17
|
+
true
|
18
|
+
end
|
19
|
+
|
20
|
+
def retry_delay(_attempt, _exception)
|
21
|
+
@retry_delay
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
attr_reader :retry_limit, :fatal_exceptions, :retry_exceptions
|
27
|
+
|
28
|
+
def retry_limit_reached?(attempt)
|
29
|
+
return false unless retry_limit
|
30
|
+
attempt >= retry_limit
|
31
|
+
end
|
32
|
+
|
33
|
+
def retry_exception?(exception)
|
34
|
+
if retry_exceptions.nil?
|
35
|
+
!exception_blacklisted?(exception)
|
36
|
+
else
|
37
|
+
exception_whitelisted?(exception)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def exception_whitelisted?(exception)
|
42
|
+
retry_exceptions.any? { |ex| exception.is_a?(ex) }
|
43
|
+
end
|
44
|
+
|
45
|
+
def exception_blacklisted?(exception)
|
46
|
+
fatal_exceptions.any? { |ex| exception.is_a?(ex) }
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
require 'active_job/retry/errors'
|
2
|
+
|
3
|
+
module ActiveJob
|
4
|
+
module Retry
|
5
|
+
class ConstantOptionsValidator
|
6
|
+
def initialize(options)
|
7
|
+
@options = options
|
8
|
+
end
|
9
|
+
|
10
|
+
def validate!
|
11
|
+
validate_limit_numericality!
|
12
|
+
validate_infinite_limit!
|
13
|
+
validate_delay!
|
14
|
+
validate_not_both_exceptions!
|
15
|
+
# Fatal exceptions must be an array (cannot be nil, since then all exceptions
|
16
|
+
# would be fatal - for that just set `limit: 0`)
|
17
|
+
validate_array_of_exceptions!(:fatal_exceptions)
|
18
|
+
# Retry exceptions must be an array of exceptions or `nil` to retry any exception
|
19
|
+
validate_array_of_exceptions!(:retry_exceptions) if options[:retry_exceptions]
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
attr_reader :options
|
25
|
+
|
26
|
+
# Limit must be an integer >= 0, or nil
|
27
|
+
def validate_limit_numericality!
|
28
|
+
return unless options[:limit]
|
29
|
+
return if options[:limit].is_a?(Fixnum) && options[:limit] >= 0
|
30
|
+
|
31
|
+
raise InvalidConfigurationError,
|
32
|
+
'Limit must be an integer >= 0, or nil for unlimited retries'
|
33
|
+
end
|
34
|
+
|
35
|
+
# If no limit is supplied, you *must* set `unlimited_retries: true` and
|
36
|
+
# understand that your ops team might hurt you.
|
37
|
+
def validate_infinite_limit!
|
38
|
+
limit = options.fetch(:limit, 1)
|
39
|
+
return unless limit.nil? ^ options[:unlimited_retries] == true
|
40
|
+
|
41
|
+
if limit.nil? && options[:unlimited_retries] != true
|
42
|
+
raise InvalidConfigurationError,
|
43
|
+
'You must set `unlimited_retries: true` to use `limit: nil`'
|
44
|
+
else
|
45
|
+
raise InvalidConfigurationError,
|
46
|
+
'You must set `limit: nil` to have unlimited retries'
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# Delay must be non-negative
|
51
|
+
def validate_delay!
|
52
|
+
return unless options[:delay]
|
53
|
+
return if options[:delay] >= 0
|
54
|
+
|
55
|
+
raise InvalidConfigurationError, 'Delay must be non-negative'
|
56
|
+
end
|
57
|
+
|
58
|
+
def validate_not_both_exceptions!
|
59
|
+
return unless options[:fatal_exceptions] && options[:retry_exceptions]
|
60
|
+
|
61
|
+
raise InvalidConfigurationError,
|
62
|
+
'fatal_exceptions and retry_exceptions cannot be used together'
|
63
|
+
end
|
64
|
+
|
65
|
+
def validate_array_of_exceptions!(key)
|
66
|
+
return unless options[key]
|
67
|
+
if options[key].is_a?(Array) &&
|
68
|
+
options[key].all? { |ex| ex.is_a?(Class) && ex <= Exception }
|
69
|
+
return
|
70
|
+
end
|
71
|
+
|
72
|
+
raise InvalidConfigurationError, "#{key} must be an array of exceptions!"
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -4,19 +4,24 @@
|
|
4
4
|
# of the deserialization as possible to the instance, i.e. `ActiveJob::Base#deserialize`,
|
5
5
|
# which can be overridden. This allows us to store extra information in the queue (i.e.
|
6
6
|
# retry_attempt), which is essential for ActiveJob::Retry.
|
7
|
+
#
|
8
|
+
# This monkey patch is automatically applied if necessary when ActiveJob::Retry is
|
9
|
+
# required.
|
7
10
|
|
8
|
-
raise
|
11
|
+
raise 'Unnecessary monkey patch!' if ActiveJob::Base.method_defined?(:deserialize)
|
9
12
|
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
13
|
+
module ActiveJob
|
14
|
+
class Base
|
15
|
+
def self.deserialize(job_data)
|
16
|
+
job = job_data['job_class'].constantize.new
|
17
|
+
job.deserialize(job_data)
|
18
|
+
job
|
19
|
+
end
|
16
20
|
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
+
def deserialize(job_data)
|
22
|
+
self.job_id = job_data['job_id']
|
23
|
+
self.queue_name = job_data['queue_name']
|
24
|
+
self.serialized_arguments = job_data['arguments']
|
25
|
+
end
|
21
26
|
end
|
22
|
-
end
|
27
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
require 'active_job/retry/constant_backoff_strategy'
|
2
|
+
require 'active_job/retry/variable_options_validator'
|
3
|
+
|
4
|
+
module ActiveJob
|
5
|
+
module Retry
|
6
|
+
class VariableBackoffStrategy < ConstantBackoffStrategy
|
7
|
+
def initialize(options)
|
8
|
+
super(options)
|
9
|
+
VariableOptionsValidator.new(options).validate!
|
10
|
+
@retry_limit = options.fetch(:delays).length + 1
|
11
|
+
@retry_delays = options.fetch(:delays)
|
12
|
+
@min_delay_multiplier = options.fetch(:min_delay_multiplier, 1.0)
|
13
|
+
@max_delay_multiplier = options.fetch(:max_delay_multiplier, 1.0)
|
14
|
+
end
|
15
|
+
|
16
|
+
def retry_delay(attempt, _exception)
|
17
|
+
(retry_delays[attempt - 1] * delay_multiplier).to_i
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
attr_reader :retry_delays, :min_delay_multiplier, :max_delay_multiplier
|
23
|
+
|
24
|
+
def random_delay?
|
25
|
+
min_delay_multiplier != max_delay_multiplier
|
26
|
+
end
|
27
|
+
|
28
|
+
def delay_multiplier
|
29
|
+
return max_delay_multiplier unless random_delay?
|
30
|
+
rand(min_delay_multiplier..max_delay_multiplier)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|