activejob-retry 0.0.1 → 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|