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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +22 -0
  3. data/.travis.yml +20 -5
  4. data/CHANGELOG.md +3 -0
  5. data/Gemfile +9 -6
  6. data/Gemfile.lock +63 -51
  7. data/LICENSE +1 -1
  8. data/README.md +57 -25
  9. data/Rakefile +14 -13
  10. data/activejob-retry.gemspec +9 -12
  11. data/lib/active_job/retry.rb +69 -90
  12. data/lib/active_job/retry/constant_backoff_strategy.rb +50 -0
  13. data/lib/active_job/retry/constant_options_validator.rb +76 -0
  14. data/lib/active_job/retry/deserialize_monkey_patch.rb +17 -12
  15. data/lib/active_job/retry/errors.rb +6 -0
  16. data/lib/active_job/retry/variable_backoff_strategy.rb +34 -0
  17. data/lib/active_job/retry/variable_options_validator.rb +56 -0
  18. data/lib/active_job/retry/version.rb +1 -1
  19. data/spec/retry/constant_backoff_strategy_spec.rb +115 -0
  20. data/spec/retry/constant_options_validator_spec.rb +81 -0
  21. data/spec/retry/variable_backoff_strategy_spec.rb +121 -0
  22. data/spec/retry/variable_options_validator_spec.rb +83 -0
  23. data/spec/retry_spec.rb +114 -170
  24. data/spec/spec_helper.rb +3 -2
  25. data/test/adapters/backburner.rb +3 -0
  26. data/test/adapters/delayed_job.rb +7 -0
  27. data/test/adapters/inline.rb +1 -0
  28. data/test/adapters/qu.rb +3 -0
  29. data/test/adapters/que.rb +4 -0
  30. data/test/adapters/queue_classic.rb +2 -0
  31. data/test/adapters/resque.rb +2 -0
  32. data/test/adapters/sidekiq.rb +2 -0
  33. data/test/adapters/sneakers.rb +2 -0
  34. data/test/adapters/sucker_punch.rb +2 -0
  35. data/test/cases/adapter_test.rb +8 -0
  36. data/test/cases/argument_serialization_test.rb +84 -0
  37. data/test/cases/callbacks_test.rb +23 -0
  38. data/test/cases/job_serialization_test.rb +15 -0
  39. data/test/cases/logging_test.rb +114 -0
  40. data/test/cases/queue_naming_test.rb +102 -0
  41. data/test/cases/queuing_test.rb +44 -0
  42. data/test/cases/rescue_test.rb +35 -0
  43. data/test/cases/test_case_test.rb +14 -0
  44. data/test/cases/test_helper_test.rb +226 -0
  45. data/test/helper.rb +21 -0
  46. data/test/integration/queuing_test.rb +46 -0
  47. data/test/jobs/callback_job.rb +31 -0
  48. data/test/jobs/gid_job.rb +10 -0
  49. data/test/jobs/hello_job.rb +9 -0
  50. data/test/jobs/logging_job.rb +12 -0
  51. data/test/jobs/nested_job.rb +12 -0
  52. data/test/jobs/rescue_job.rb +31 -0
  53. data/test/models/person.rb +20 -0
  54. data/test/support/backburner/inline.rb +8 -0
  55. data/test/support/delayed_job/delayed/backend/test.rb +111 -0
  56. data/test/support/delayed_job/delayed/serialization/test.rb +0 -0
  57. data/test/support/integration/adapters/backburner.rb +38 -0
  58. data/test/support/integration/adapters/delayed_job.rb +20 -0
  59. data/test/support/integration/adapters/que.rb +37 -0
  60. data/test/support/integration/adapters/resque.rb +49 -0
  61. data/test/support/integration/adapters/sidekiq.rb +58 -0
  62. data/test/support/integration/dummy_app_template.rb +28 -0
  63. data/test/support/integration/helper.rb +30 -0
  64. data/test/support/integration/jobs_manager.rb +27 -0
  65. data/test/support/integration/test_case_helpers.rb +48 -0
  66. data/test/support/job_buffer.rb +19 -0
  67. data/test/support/que/inline.rb +9 -0
  68. metadata +60 -12
  69. data/lib/active_job-retry.rb +0 -14
  70. data/lib/active_job/retry/exponential_backoff.rb +0 -92
  71. data/lib/active_job/retry/exponential_options_validator.rb +0 -57
  72. data/lib/active_job/retry/invalid_configuration_error.rb +0 -6
  73. data/lib/active_job/retry/options_validator.rb +0 -84
@@ -1,7 +1,4 @@
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'
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 retrying DSL for ActiveJob'
9
+ s.summary = 'Automatic retry functionality for ActiveJob.'
13
10
  s.description = <<-EOL
14
- activejob-retry provides a simple DSL for automatically retrying ActiveJobs when they
15
- fail, with exponential backoff.
11
+ activejob-retry provides automatic retry functionality for failed
12
+ ActiveJobs, with exponential backoff.
16
13
 
17
14
  Features:
18
15
 
19
- * (Should) work with any queue adapter that supports retries.
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/isaacseymour/activejob-retry'
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[lib]
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('pry-byebug')
34
+ s.add_development_dependency('rubocop')
38
35
  end
@@ -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
- 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
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
- # 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
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
- 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
32
+ #################
33
+ # Configuration #
34
+ #################
42
35
 
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
36
+ module ClassMethods
37
+ attr_reader :backoff_strategy
53
38
 
54
- def fatal_exceptions
55
- @fatal_exceptions ||= []
39
+ def constant_retry(options)
40
+ retry_with(ConstantBackoffStrategy.new(options))
56
41
  end
57
42
 
58
- def retry_exceptions
59
- @retry_exceptions ||= nil
43
+ def variable_retry(options)
44
+ retry_with(VariableBackoffStrategy.new(options))
60
45
  end
61
46
 
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
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
- def exception_whitelisted?(exception)
72
- retry_exceptions.any? { |ex| exception.is_a?(ex) }
54
+ @backoff_strategy = backoff_strategy
73
55
  end
74
56
 
75
- def exception_blacklisted?(exception)
76
- fatal_exceptions.any? { |ex| exception.is_a?(ex) }
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
- # Overrides #
82
- #############
65
+ #############################
66
+ # Storage of attempt number #
67
+ #############################
68
+
83
69
  def serialize
84
- super.merge('retry_attempt' => retry_attempt + 1)
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 ||= 0
79
+ @retry_attempt ||= 1
103
80
  end
104
81
 
105
- # Override me if you want more complex behaviour
106
- def retry_delay
107
- self.class.retry_delay
108
- end
82
+ ##########################
83
+ # Performing the retries #
84
+ ##########################
109
85
 
110
- def should_retry?(exception)
111
- return false if retry_limit_reached?
112
- return false unless self.class.retry_exception?(exception)
113
- true
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
- 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
92
+ private
121
93
 
122
94
  def retry_or_reraise(exception)
123
- raise exception unless should_retry?(exception)
95
+ unless self.class.backoff_strategy.should_retry?(retry_attempt, exception)
96
+ raise exception
97
+ end
124
98
 
125
- this_delay = retry_delay
126
- logger.log(Logger::INFO, "Retrying (attempt #{retry_attempt + 1}, waiting #{this_delay}s)")
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 "Unnecessary monkey patch!" if ActiveJob::Base.method_defined?(:deserialize)
11
+ raise 'Unnecessary monkey patch!' if ActiveJob::Base.method_defined?(:deserialize)
9
12
 
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
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
- 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
+ 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,6 @@
1
+ module ActiveJob
2
+ module Retry
3
+ class InvalidConfigurationError < StandardError; end
4
+ class UnsupportedAdapterError < StandardError; end
5
+ end
6
+ 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