retriable 3.5.0 → 4.2.0
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/.github/workflows/main.yml +38 -9
- data/.hound.yml +1 -1
- data/.rubocop.yml +4 -1
- data/CHANGELOG.md +113 -0
- data/Gemfile +4 -1
- data/README.md +197 -110
- data/docs/testing.md +212 -0
- data/lib/retriable/config.rb +81 -10
- data/lib/retriable/core_ext/kernel.rb +6 -4
- data/lib/retriable/exponential_backoff.rb +44 -10
- data/lib/retriable/validation.rb +95 -0
- data/lib/retriable/version.rb +1 -1
- data/lib/retriable.rb +121 -57
- data/retriable.gemspec +2 -7
- data/sig/retriable.rbs +29 -1
- data/spec/config_spec.rb +157 -4
- data/spec/exponential_backoff_spec.rb +45 -26
- data/spec/retriable_spec.rb +739 -87
- data/spec/spec_helper.rb +3 -1
- metadata +9 -53
data/lib/retriable/config.rb
CHANGED
|
@@ -1,37 +1,43 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "exponential_backoff"
|
|
4
|
+
require_relative "validation"
|
|
4
5
|
|
|
5
6
|
module Retriable
|
|
6
7
|
class Config
|
|
8
|
+
include Validation
|
|
9
|
+
|
|
7
10
|
ATTRIBUTES = (ExponentialBackoff::ATTRIBUTES + %i[
|
|
8
11
|
sleep_disabled
|
|
9
12
|
max_elapsed_time
|
|
10
13
|
intervals
|
|
11
|
-
timeout
|
|
12
14
|
on
|
|
13
15
|
retry_if
|
|
14
16
|
on_retry
|
|
17
|
+
on_give_up
|
|
15
18
|
contexts
|
|
16
19
|
]).freeze
|
|
17
20
|
|
|
21
|
+
CONTEXT_ATTRIBUTES = (ATTRIBUTES - %i[contexts]).freeze
|
|
22
|
+
private_constant :CONTEXT_ATTRIBUTES
|
|
23
|
+
|
|
18
24
|
attr_accessor(*ATTRIBUTES)
|
|
19
25
|
|
|
20
26
|
def initialize(opts = {})
|
|
21
|
-
|
|
27
|
+
defaults = ExponentialBackoff::DEFAULTS
|
|
22
28
|
|
|
23
|
-
@tries =
|
|
24
|
-
@base_interval =
|
|
25
|
-
@max_interval =
|
|
26
|
-
@rand_factor =
|
|
27
|
-
@multiplier =
|
|
29
|
+
@tries = defaults[:tries]
|
|
30
|
+
@base_interval = defaults[:base_interval]
|
|
31
|
+
@max_interval = defaults[:max_interval]
|
|
32
|
+
@rand_factor = defaults[:rand_factor]
|
|
33
|
+
@multiplier = defaults[:multiplier]
|
|
28
34
|
@sleep_disabled = false
|
|
29
35
|
@max_elapsed_time = 900 # 15 min
|
|
30
36
|
@intervals = nil
|
|
31
|
-
@timeout = nil
|
|
32
37
|
@on = [StandardError]
|
|
33
38
|
@retry_if = nil
|
|
34
39
|
@on_retry = nil
|
|
40
|
+
@on_give_up = nil
|
|
35
41
|
@contexts = {}
|
|
36
42
|
|
|
37
43
|
opts.each do |k, v|
|
|
@@ -39,12 +45,77 @@ module Retriable
|
|
|
39
45
|
|
|
40
46
|
instance_variable_set(:"@#{k}", v)
|
|
41
47
|
end
|
|
48
|
+
|
|
49
|
+
validate!
|
|
42
50
|
end
|
|
43
51
|
|
|
44
52
|
def to_h
|
|
45
|
-
ATTRIBUTES.
|
|
46
|
-
|
|
53
|
+
ATTRIBUTES.to_h { |key| [key, public_send(key)] }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def validate!
|
|
57
|
+
validate_contexts
|
|
58
|
+
validate_callable(:retry_if, retry_if)
|
|
59
|
+
validate_callable(:on_retry, on_retry)
|
|
60
|
+
validate_callable(:on_give_up, on_give_up)
|
|
61
|
+
validate_on(on)
|
|
62
|
+
validate_intervals
|
|
63
|
+
if unbounded_tries?(tries)
|
|
64
|
+
validate_unbounded_tries
|
|
65
|
+
else
|
|
66
|
+
validate_optional_non_negative_number(:max_elapsed_time, max_elapsed_time)
|
|
67
|
+
return if intervals
|
|
68
|
+
|
|
69
|
+
validate_positive_integer(:tries, tries)
|
|
47
70
|
end
|
|
71
|
+
|
|
72
|
+
validate_backoff_options
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def validate_contexts
|
|
78
|
+
return unless contexts.is_a?(Hash)
|
|
79
|
+
return if contexts.empty?
|
|
80
|
+
|
|
81
|
+
contexts.each_value do |options|
|
|
82
|
+
next unless options.is_a?(Hash)
|
|
83
|
+
|
|
84
|
+
options.each_key do |k|
|
|
85
|
+
next if CONTEXT_ATTRIBUTES.include?(k)
|
|
86
|
+
|
|
87
|
+
raise ArgumentError, "#{k} is not a valid option"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def validate_backoff_options
|
|
93
|
+
validate_non_negative_number(:base_interval, base_interval)
|
|
94
|
+
validate_non_negative_number(:multiplier, multiplier)
|
|
95
|
+
validate_non_negative_number(:max_interval, max_interval)
|
|
96
|
+
validate_rand_factor
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def validate_unbounded_tries
|
|
100
|
+
if intervals
|
|
101
|
+
raise ArgumentError,
|
|
102
|
+
"intervals cannot be used with tries: Float::INFINITY"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
unless finite_number?(max_elapsed_time)
|
|
106
|
+
raise ArgumentError,
|
|
107
|
+
"max_elapsed_time must be a finite number when tries is Float::INFINITY"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
validate_non_negative_number(:max_elapsed_time, max_elapsed_time)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def validate_intervals
|
|
114
|
+
return if intervals.nil?
|
|
115
|
+
raise ArgumentError, "intervals must be an Array" unless intervals.is_a?(Array)
|
|
116
|
+
return if intervals.all? { |interval| finite_number?(interval) && interval >= 0 }
|
|
117
|
+
|
|
118
|
+
raise ArgumentError, "intervals must contain only non-negative numbers"
|
|
48
119
|
end
|
|
49
120
|
end
|
|
50
121
|
end
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
require_relative "../../retriable"
|
|
4
4
|
|
|
5
5
|
module Kernel
|
|
6
|
-
def retriable(opts = {}, &
|
|
7
|
-
Retriable.retriable(opts, &
|
|
6
|
+
def retriable(opts = {}, &)
|
|
7
|
+
Retriable.retriable(opts, &)
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
def retriable_with_context(context_key, opts = {}, &
|
|
11
|
-
Retriable.with_context(context_key, opts, &
|
|
10
|
+
def retriable_with_context(context_key, opts = {}, &)
|
|
11
|
+
Retriable.with_context(context_key, opts, &)
|
|
12
12
|
end
|
|
13
|
+
|
|
14
|
+
private :retriable, :retriable_with_context
|
|
13
15
|
end
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "validation"
|
|
4
|
+
|
|
3
5
|
module Retriable
|
|
4
6
|
class ExponentialBackoff
|
|
7
|
+
include Validation
|
|
8
|
+
|
|
5
9
|
ATTRIBUTES = %i[
|
|
6
10
|
tries
|
|
7
11
|
base_interval
|
|
@@ -10,34 +14,64 @@ module Retriable
|
|
|
10
14
|
rand_factor
|
|
11
15
|
].freeze
|
|
12
16
|
|
|
17
|
+
DEFAULTS = {
|
|
18
|
+
tries: 3,
|
|
19
|
+
base_interval: 0.5,
|
|
20
|
+
max_interval: 60,
|
|
21
|
+
rand_factor: 0.5,
|
|
22
|
+
multiplier: 1.5
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
13
25
|
attr_accessor(*ATTRIBUTES)
|
|
14
26
|
|
|
15
27
|
def initialize(opts = {})
|
|
16
|
-
@tries =
|
|
17
|
-
@base_interval =
|
|
18
|
-
@max_interval =
|
|
19
|
-
@rand_factor =
|
|
20
|
-
@multiplier =
|
|
28
|
+
@tries = DEFAULTS[:tries]
|
|
29
|
+
@base_interval = DEFAULTS[:base_interval]
|
|
30
|
+
@max_interval = DEFAULTS[:max_interval]
|
|
31
|
+
@rand_factor = DEFAULTS[:rand_factor]
|
|
32
|
+
@multiplier = DEFAULTS[:multiplier]
|
|
21
33
|
|
|
22
34
|
opts.each do |k, v|
|
|
23
35
|
raise ArgumentError, "#{k} is not a valid option" if !ATTRIBUTES.include?(k)
|
|
24
36
|
|
|
25
37
|
instance_variable_set(:"@#{k}", v)
|
|
26
38
|
end
|
|
39
|
+
|
|
40
|
+
validate!
|
|
27
41
|
end
|
|
28
42
|
|
|
29
43
|
def intervals
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
44
|
+
provider = interval_provider
|
|
45
|
+
Array.new(tries) { |iteration| provider.call(iteration) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def interval_provider
|
|
49
|
+
raw_interval = base_interval
|
|
33
50
|
|
|
34
|
-
|
|
51
|
+
lambda do |_iteration|
|
|
52
|
+
interval = [raw_interval, max_interval].min
|
|
53
|
+
raw_interval = next_raw_interval(raw_interval)
|
|
35
54
|
|
|
36
|
-
|
|
55
|
+
rand_factor.zero? ? interval : randomize(interval)
|
|
56
|
+
end
|
|
37
57
|
end
|
|
38
58
|
|
|
39
59
|
private
|
|
40
60
|
|
|
61
|
+
def validate!
|
|
62
|
+
validate_non_negative_integer(:tries, tries)
|
|
63
|
+
validate_non_negative_number(:base_interval, base_interval)
|
|
64
|
+
validate_non_negative_number(:multiplier, multiplier)
|
|
65
|
+
validate_non_negative_number(:max_interval, max_interval)
|
|
66
|
+
validate_rand_factor
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def next_raw_interval(raw_interval)
|
|
70
|
+
return max_interval if multiplier >= 1 && raw_interval >= max_interval
|
|
71
|
+
|
|
72
|
+
raw_interval * multiplier
|
|
73
|
+
end
|
|
74
|
+
|
|
41
75
|
def randomize(interval)
|
|
42
76
|
delta = rand_factor * interval.to_f
|
|
43
77
|
min = interval - delta
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Retriable
|
|
4
|
+
module Validation
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def validate_positive_integer(name, value)
|
|
8
|
+
return if value.is_a?(Integer) && value.positive?
|
|
9
|
+
|
|
10
|
+
raise ArgumentError, "#{name} must be a positive integer"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def validate_non_negative_integer(name, value)
|
|
14
|
+
return if value.is_a?(Integer) && value >= 0
|
|
15
|
+
|
|
16
|
+
raise ArgumentError, "#{name} must be a non-negative integer"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def validate_non_negative_number(name, value)
|
|
20
|
+
return if finite_number?(value) && value >= 0
|
|
21
|
+
|
|
22
|
+
raise ArgumentError, "#{name} must be a non-negative number"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def validate_optional_non_negative_number(name, value)
|
|
26
|
+
return if value.nil?
|
|
27
|
+
|
|
28
|
+
validate_non_negative_number(name, value)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def validate_callable(name, value)
|
|
32
|
+
return unless value # nil/false disable the callback
|
|
33
|
+
return if value.respond_to?(:call)
|
|
34
|
+
|
|
35
|
+
raise ArgumentError, "#{name} must respond to #call or be nil"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def validate_rand_factor
|
|
39
|
+
return if finite_number?(rand_factor) && rand_factor >= 0 && rand_factor <= 1
|
|
40
|
+
|
|
41
|
+
raise ArgumentError, "rand_factor must be between 0 and 1"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def finite_number?(value)
|
|
45
|
+
value.is_a?(Numeric) && value.to_f.finite?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def unbounded_tries?(value)
|
|
49
|
+
value.is_a?(Numeric) && value.respond_to?(:infinite?) && value.infinite? == 1
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
module_function :unbounded_tries?
|
|
53
|
+
|
|
54
|
+
# Validates an `on:` value. Acceptable shapes:
|
|
55
|
+
# - a Class that descends from Exception
|
|
56
|
+
# - an Array or Set whose elements are Classes that descend from Exception
|
|
57
|
+
# - a Hash whose keys are such Classes and whose values are nil,
|
|
58
|
+
# a Regexp, or an Array of Regexps
|
|
59
|
+
#
|
|
60
|
+
# Without this validation, callers can pass values like `Object` or
|
|
61
|
+
# `Kernel` and silently retry process-critical exceptions such as
|
|
62
|
+
# SystemExit and Interrupt, because every Exception's ancestor chain
|
|
63
|
+
# includes both. Hash values that are not Regexps (e.g. plain Strings)
|
|
64
|
+
# also silently fail to match in #hash_exception_match?, so we require
|
|
65
|
+
# Regexp values explicitly.
|
|
66
|
+
def validate_on(value)
|
|
67
|
+
case value
|
|
68
|
+
in Hash
|
|
69
|
+
value.each do |klass, pattern|
|
|
70
|
+
validate_on_class(klass)
|
|
71
|
+
validate_on_hash_value(klass, pattern)
|
|
72
|
+
end
|
|
73
|
+
in Array | Set
|
|
74
|
+
value.each { |klass| validate_on_class(klass) }
|
|
75
|
+
else
|
|
76
|
+
validate_on_class(value)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def validate_on_class(klass)
|
|
81
|
+
return if klass.is_a?(Class) && klass <= Exception
|
|
82
|
+
|
|
83
|
+
raise ArgumentError, "on must be an Exception class or a collection of Exception classes, got #{klass.inspect}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def validate_on_hash_value(klass, pattern)
|
|
87
|
+
return if pattern.nil?
|
|
88
|
+
return if pattern.is_a?(Regexp)
|
|
89
|
+
return if pattern.is_a?(Array) && pattern.all?(Regexp)
|
|
90
|
+
|
|
91
|
+
raise ArgumentError,
|
|
92
|
+
"on[#{klass}] must be nil, a Regexp, or an Array of Regexps, got #{pattern.inspect}"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
data/lib/retriable/version.rb
CHANGED
data/lib/retriable.rb
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "timeout"
|
|
4
3
|
require_relative "retriable/config"
|
|
5
4
|
require_relative "retriable/exponential_backoff"
|
|
6
5
|
require_relative "retriable/version"
|
|
7
6
|
|
|
8
7
|
module Retriable
|
|
8
|
+
# Thread-local storage key for the active #with_override block.
|
|
9
|
+
# We deliberately use Thread#thread_variable_set/get (true thread-local)
|
|
10
|
+
# rather than Thread.current[] (fiber-local) so that fibers within a thread
|
|
11
|
+
# share the same override. Changing this to Thread.current[] would silently
|
|
12
|
+
# break callers that use fiber-based concurrency.
|
|
13
|
+
OVERRIDE_THREAD_KEY = :retriable_override
|
|
14
|
+
|
|
15
|
+
RetryPlan = Struct.new(:max_tries, :interval_for)
|
|
16
|
+
private_constant :RetryPlan
|
|
17
|
+
|
|
9
18
|
module_function
|
|
10
19
|
|
|
11
20
|
def configure
|
|
@@ -16,18 +25,24 @@ module Retriable
|
|
|
16
25
|
@config ||= Config.new
|
|
17
26
|
end
|
|
18
27
|
|
|
19
|
-
def
|
|
20
|
-
raise ArgumentError, "empty override options are not allowed
|
|
28
|
+
def with_override(opts = {})
|
|
29
|
+
raise ArgumentError, "empty override options are not allowed" if opts.empty?
|
|
30
|
+
raise ArgumentError, "with_override requires a block" unless block_given?
|
|
21
31
|
|
|
22
32
|
validate_override_options(opts)
|
|
23
|
-
@override_config = opts
|
|
24
|
-
end
|
|
25
33
|
|
|
26
|
-
|
|
27
|
-
|
|
34
|
+
previous = Thread.current.thread_variable_get(OVERRIDE_THREAD_KEY)
|
|
35
|
+
Thread.current.thread_variable_set(OVERRIDE_THREAD_KEY, opts)
|
|
36
|
+
begin
|
|
37
|
+
yield
|
|
38
|
+
ensure
|
|
39
|
+
Thread.current.thread_variable_set(OVERRIDE_THREAD_KEY, previous)
|
|
40
|
+
end
|
|
28
41
|
end
|
|
29
42
|
|
|
30
|
-
def with_context(context_key, options = {}, &
|
|
43
|
+
def with_context(context_key, options = {}, &)
|
|
44
|
+
raise ArgumentError, "with_context requires a block" unless block_given?
|
|
45
|
+
|
|
31
46
|
contexts = available_contexts
|
|
32
47
|
|
|
33
48
|
if !contexts.key?(context_key)
|
|
@@ -35,24 +50,25 @@ module Retriable
|
|
|
35
50
|
"#{context_key} not found in Retriable contexts (including overrides). Available contexts: #{contexts.keys}"
|
|
36
51
|
end
|
|
37
52
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
retriable(context_options_for(context_key, options), &block)
|
|
53
|
+
retriable(context_options_for(context_key, options), &)
|
|
41
54
|
end
|
|
42
55
|
|
|
43
|
-
def retriable(opts = {}, &
|
|
44
|
-
|
|
56
|
+
def retriable(opts = {}, &)
|
|
57
|
+
override_config = current_override
|
|
58
|
+
local_config = if opts.empty? && !override_config
|
|
45
59
|
config
|
|
46
60
|
else
|
|
47
|
-
Config.new(apply_override_options(config.to_h
|
|
61
|
+
Config.new(apply_override_options(merge_layer(config.to_h, opts), override_config))
|
|
48
62
|
end
|
|
49
63
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
64
|
+
# Config is mutable through `configure`, so validate again immediately before use.
|
|
65
|
+
local_config.validate!
|
|
66
|
+
|
|
67
|
+
plan = retry_plan(local_config)
|
|
53
68
|
on = local_config.on
|
|
54
69
|
retry_if = local_config.retry_if
|
|
55
70
|
on_retry = local_config.on_retry
|
|
71
|
+
on_give_up = local_config.on_give_up
|
|
56
72
|
sleep_disabled = local_config.sleep_disabled
|
|
57
73
|
max_elapsed_time = local_config.max_elapsed_time
|
|
58
74
|
|
|
@@ -61,55 +77,67 @@ module Retriable
|
|
|
61
77
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
62
78
|
elapsed_time = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time }
|
|
63
79
|
|
|
64
|
-
tries = intervals.size + 1
|
|
65
|
-
|
|
66
80
|
execute_tries(
|
|
67
|
-
|
|
81
|
+
max_tries: plan.max_tries, interval_for: plan.interval_for,
|
|
68
82
|
exception_list: exception_list, on: on, retry_if: retry_if, on_retry: on_retry,
|
|
69
|
-
elapsed_time: elapsed_time, max_elapsed_time: max_elapsed_time,
|
|
70
|
-
sleep_disabled: sleep_disabled, &
|
|
83
|
+
on_give_up: on_give_up, elapsed_time: elapsed_time, max_elapsed_time: max_elapsed_time,
|
|
84
|
+
sleep_disabled: sleep_disabled, &
|
|
71
85
|
)
|
|
72
86
|
end
|
|
73
87
|
|
|
74
88
|
def execute_tries( # rubocop:disable Metrics/ParameterLists
|
|
75
|
-
|
|
76
|
-
on:, retry_if:, on_retry:, elapsed_time:, max_elapsed_time:, sleep_disabled
|
|
89
|
+
max_tries:, interval_for:, exception_list:,
|
|
90
|
+
on:, retry_if:, on_retry:, on_give_up:, elapsed_time:, max_elapsed_time:, sleep_disabled:
|
|
77
91
|
)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
92
|
+
try = 0
|
|
93
|
+
loop do
|
|
94
|
+
try += 1
|
|
81
95
|
begin
|
|
82
|
-
return
|
|
96
|
+
return yield(try)
|
|
83
97
|
rescue *exception_list => e
|
|
84
98
|
raise unless retriable_exception?(e, on, exception_list, retry_if)
|
|
85
99
|
|
|
86
|
-
|
|
100
|
+
# On the final attempt `interval_for` returns nil (no next retry), and
|
|
101
|
+
# `on_retry` intentionally fires before the give-up check below, so it
|
|
102
|
+
# receives `interval: nil`. See the on_retry/on_give_up README contract.
|
|
103
|
+
interval = interval_for.call(try - 1)
|
|
87
104
|
call_on_retry(on_retry, e, try, elapsed_time.call, interval)
|
|
88
105
|
|
|
89
106
|
elapsed_interval = sleep_disabled == true ? 0 : interval
|
|
90
|
-
|
|
107
|
+
# Snapshot elapsed_time once so the stop check and on_give_up see the same value.
|
|
108
|
+
current_elapsed_time = elapsed_time.call
|
|
109
|
+
stop_reason = retry_stop_reason(try, max_tries, current_elapsed_time, elapsed_interval, max_elapsed_time)
|
|
110
|
+
if stop_reason
|
|
111
|
+
call_on_give_up(on_give_up, e, try, current_elapsed_time, interval, stop_reason)
|
|
112
|
+
raise
|
|
113
|
+
end
|
|
91
114
|
|
|
92
115
|
sleep interval if sleep_disabled != true
|
|
93
116
|
end
|
|
94
117
|
end
|
|
95
118
|
end
|
|
96
119
|
|
|
97
|
-
def
|
|
98
|
-
return
|
|
120
|
+
def retry_plan(local_config)
|
|
121
|
+
return RetryPlan.new(nil, interval_provider(local_config)) if Validation.unbounded_tries?(local_config.tries)
|
|
122
|
+
|
|
123
|
+
if local_config.intervals
|
|
124
|
+
intervals = local_config.intervals
|
|
125
|
+
return RetryPlan.new(intervals.size + 1, ->(index) { intervals[index] })
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
max_tries = local_config.tries
|
|
129
|
+
provider = interval_provider(local_config)
|
|
99
130
|
|
|
131
|
+
RetryPlan.new(max_tries, ->(index) { index < max_tries - 1 ? provider.call(index) : nil })
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def interval_provider(local_config)
|
|
100
135
|
ExponentialBackoff.new(
|
|
101
|
-
tries: tries - 1,
|
|
102
136
|
base_interval: local_config.base_interval,
|
|
103
137
|
multiplier: local_config.multiplier,
|
|
104
138
|
max_interval: local_config.max_interval,
|
|
105
139
|
rand_factor: local_config.rand_factor,
|
|
106
|
-
).
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def call_with_timeout(timeout, try)
|
|
110
|
-
return Timeout.timeout(timeout) { yield(try) } if timeout
|
|
111
|
-
|
|
112
|
-
yield(try)
|
|
140
|
+
).interval_provider
|
|
113
141
|
end
|
|
114
142
|
|
|
115
143
|
def call_on_retry(on_retry, exception, try, elapsed_time, interval)
|
|
@@ -118,11 +146,24 @@ module Retriable
|
|
|
118
146
|
on_retry.call(exception, try, elapsed_time, interval)
|
|
119
147
|
end
|
|
120
148
|
|
|
121
|
-
def
|
|
122
|
-
|
|
123
|
-
|
|
149
|
+
def call_on_give_up( # rubocop:disable Metrics/ParameterLists
|
|
150
|
+
on_give_up, exception, try, elapsed_time, interval, reason
|
|
151
|
+
)
|
|
152
|
+
return unless on_give_up
|
|
124
153
|
|
|
125
|
-
(elapsed_time
|
|
154
|
+
on_give_up.call(exception, try, elapsed_time, interval, reason)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# `:tries_exhausted` is checked first, but the two conditions can't both hold
|
|
158
|
+
# on the same try in practice: `retry_plan` returns a nil interval whenever
|
|
159
|
+
# `try >= max_tries`, so `(elapsed_time + interval) > max_elapsed_time` is not
|
|
160
|
+
# evaluable on the exhausted-tries try. The early return guards against that
|
|
161
|
+
# nil and also pins precedence in case the plan ever changes.
|
|
162
|
+
def retry_stop_reason(try, max_tries, elapsed_time, interval, max_elapsed_time)
|
|
163
|
+
return :tries_exhausted if max_tries && try >= max_tries
|
|
164
|
+
return nil if max_elapsed_time.nil?
|
|
165
|
+
|
|
166
|
+
:max_elapsed_time if (elapsed_time + interval) > max_elapsed_time
|
|
126
167
|
end
|
|
127
168
|
|
|
128
169
|
# When `on` is a Hash, we need to verify the exception matches a pattern.
|
|
@@ -151,16 +192,23 @@ module Retriable
|
|
|
151
192
|
raise ArgumentError, "#{k} is not a valid option" unless Config::ATTRIBUTES.include?(k)
|
|
152
193
|
end
|
|
153
194
|
|
|
195
|
+
return unless opts.key?(:contexts)
|
|
196
|
+
|
|
154
197
|
contexts = opts[:contexts]
|
|
155
|
-
return
|
|
198
|
+
return if contexts.nil?
|
|
156
199
|
|
|
157
|
-
contexts.
|
|
158
|
-
|
|
200
|
+
raise ArgumentError, "contexts must be a Hash or nil, got #{contexts.inspect}" unless contexts.is_a?(Hash)
|
|
201
|
+
|
|
202
|
+
contexts.each do |context_key, context_options|
|
|
203
|
+
validate_context_override_options(context_key, context_options)
|
|
159
204
|
end
|
|
160
205
|
end
|
|
161
206
|
|
|
162
|
-
def validate_context_override_options(context_options)
|
|
163
|
-
|
|
207
|
+
def validate_context_override_options(context_key, context_options)
|
|
208
|
+
unless context_options.is_a?(Hash)
|
|
209
|
+
raise ArgumentError,
|
|
210
|
+
"contexts[#{context_key.inspect}] must be a Hash, got #{context_options.inspect}"
|
|
211
|
+
end
|
|
164
212
|
|
|
165
213
|
context_attributes = Config::ATTRIBUTES - [:contexts]
|
|
166
214
|
context_options.each_key do |k|
|
|
@@ -171,9 +219,17 @@ module Retriable
|
|
|
171
219
|
def apply_override_options(options, overrides)
|
|
172
220
|
return options unless overrides
|
|
173
221
|
|
|
174
|
-
options
|
|
175
|
-
|
|
176
|
-
|
|
222
|
+
merge_layer(options, overrides)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Merge a higher-precedence option layer onto a base layer. A higher layer
|
|
226
|
+
# that sets `tries` without `intervals` clears the base layer's inherited
|
|
227
|
+
# `intervals`, so a caller's `tries:` is never silently ignored. When the
|
|
228
|
+
# higher layer supplies its own `intervals`, those win (same-call override).
|
|
229
|
+
def merge_layer(base, higher)
|
|
230
|
+
merged = base.merge(higher)
|
|
231
|
+
merged[:intervals] = nil if higher.key?(:tries) && !higher.key?(:intervals)
|
|
232
|
+
merged
|
|
177
233
|
end
|
|
178
234
|
|
|
179
235
|
def available_contexts
|
|
@@ -183,7 +239,7 @@ module Retriable
|
|
|
183
239
|
def context_options_for(context_key, options)
|
|
184
240
|
context_options = config_contexts.fetch(context_key, {})
|
|
185
241
|
context_options = {} unless context_options.is_a?(Hash)
|
|
186
|
-
context_options = context_options
|
|
242
|
+
context_options = merge_layer(context_options, options)
|
|
187
243
|
|
|
188
244
|
override_context_options = override_contexts[context_key]
|
|
189
245
|
return context_options unless override_context_options.is_a?(Hash)
|
|
@@ -196,24 +252,32 @@ module Retriable
|
|
|
196
252
|
end
|
|
197
253
|
|
|
198
254
|
def override_contexts
|
|
199
|
-
|
|
255
|
+
override_config = current_override
|
|
256
|
+
contexts = override_config && override_config[:contexts]
|
|
200
257
|
contexts.is_a?(Hash) ? contexts : {}
|
|
201
258
|
end
|
|
202
259
|
|
|
260
|
+
def current_override
|
|
261
|
+
Thread.current.thread_variable_get(OVERRIDE_THREAD_KEY)
|
|
262
|
+
end
|
|
263
|
+
|
|
203
264
|
private_class_method(
|
|
204
265
|
:validate_override_options,
|
|
205
266
|
:validate_context_override_options,
|
|
206
267
|
:execute_tries,
|
|
207
|
-
:
|
|
208
|
-
:
|
|
268
|
+
:retry_plan,
|
|
269
|
+
:interval_provider,
|
|
209
270
|
:call_on_retry,
|
|
210
|
-
:
|
|
271
|
+
:call_on_give_up,
|
|
272
|
+
:retry_stop_reason,
|
|
211
273
|
:retriable_exception?,
|
|
212
274
|
:hash_exception_match?,
|
|
213
275
|
:apply_override_options,
|
|
276
|
+
:merge_layer,
|
|
214
277
|
:available_contexts,
|
|
215
278
|
:context_options_for,
|
|
216
279
|
:config_contexts,
|
|
217
280
|
:override_contexts,
|
|
281
|
+
:current_override,
|
|
218
282
|
)
|
|
219
283
|
end
|
data/retriable.gemspec
CHANGED
|
@@ -15,15 +15,10 @@ Gem::Specification.new do |spec|
|
|
|
15
15
|
"APIs/services or file system calls."
|
|
16
16
|
spec.homepage = "https://github.com/kamui/retriable"
|
|
17
17
|
spec.license = "MIT"
|
|
18
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
18
19
|
|
|
19
20
|
spec.files = `git ls-files -z`.split("\x0")
|
|
20
|
-
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
|
21
21
|
spec.require_paths = ["lib"]
|
|
22
22
|
|
|
23
|
-
spec.required_ruby_version = ">=
|
|
24
|
-
|
|
25
|
-
spec.add_development_dependency "bundler"
|
|
26
|
-
spec.add_development_dependency "rspec", "~> 3"
|
|
27
|
-
|
|
28
|
-
spec.add_development_dependency "listen", "~> 3.1"
|
|
23
|
+
spec.required_ruby_version = ">= 3.2"
|
|
29
24
|
end
|