retriable 3.4.1 → 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 +43 -11
- data/.hound.yml +1 -1
- data/.rubocop.yml +4 -1
- data/CHANGELOG.md +118 -0
- data/Gemfile +4 -1
- data/README.md +214 -95
- 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 +185 -42
- 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 +915 -6
- 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,26 +25,50 @@ module Retriable
|
|
|
16
25
|
@config ||= Config.new
|
|
17
26
|
end
|
|
18
27
|
|
|
19
|
-
def
|
|
20
|
-
if
|
|
21
|
-
|
|
22
|
-
|
|
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?
|
|
31
|
+
|
|
32
|
+
validate_override_options(opts)
|
|
33
|
+
|
|
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)
|
|
23
40
|
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def with_context(context_key, options = {}, &)
|
|
44
|
+
raise ArgumentError, "with_context requires a block" unless block_given?
|
|
24
45
|
|
|
25
|
-
|
|
46
|
+
contexts = available_contexts
|
|
26
47
|
|
|
27
|
-
|
|
48
|
+
if !contexts.key?(context_key)
|
|
49
|
+
raise ArgumentError,
|
|
50
|
+
"#{context_key} not found in Retriable contexts (including overrides). Available contexts: #{contexts.keys}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
retriable(context_options_for(context_key, options), &)
|
|
28
54
|
end
|
|
29
55
|
|
|
30
|
-
def retriable(opts = {}, &
|
|
31
|
-
|
|
56
|
+
def retriable(opts = {}, &)
|
|
57
|
+
override_config = current_override
|
|
58
|
+
local_config = if opts.empty? && !override_config
|
|
59
|
+
config
|
|
60
|
+
else
|
|
61
|
+
Config.new(apply_override_options(merge_layer(config.to_h, opts), override_config))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Config is mutable through `configure`, so validate again immediately before use.
|
|
65
|
+
local_config.validate!
|
|
32
66
|
|
|
33
|
-
|
|
34
|
-
intervals = build_intervals(local_config, tries)
|
|
35
|
-
timeout = local_config.timeout
|
|
67
|
+
plan = retry_plan(local_config)
|
|
36
68
|
on = local_config.on
|
|
37
69
|
retry_if = local_config.retry_if
|
|
38
70
|
on_retry = local_config.on_retry
|
|
71
|
+
on_give_up = local_config.on_give_up
|
|
39
72
|
sleep_disabled = local_config.sleep_disabled
|
|
40
73
|
max_elapsed_time = local_config.max_elapsed_time
|
|
41
74
|
|
|
@@ -44,54 +77,67 @@ module Retriable
|
|
|
44
77
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
45
78
|
elapsed_time = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time }
|
|
46
79
|
|
|
47
|
-
tries = intervals.size + 1
|
|
48
|
-
|
|
49
80
|
execute_tries(
|
|
50
|
-
|
|
81
|
+
max_tries: plan.max_tries, interval_for: plan.interval_for,
|
|
51
82
|
exception_list: exception_list, on: on, retry_if: retry_if, on_retry: on_retry,
|
|
52
|
-
elapsed_time: elapsed_time, max_elapsed_time: max_elapsed_time,
|
|
53
|
-
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, &
|
|
54
85
|
)
|
|
55
86
|
end
|
|
56
87
|
|
|
57
88
|
def execute_tries( # rubocop:disable Metrics/ParameterLists
|
|
58
|
-
|
|
59
|
-
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:
|
|
60
91
|
)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
92
|
+
try = 0
|
|
93
|
+
loop do
|
|
94
|
+
try += 1
|
|
64
95
|
begin
|
|
65
|
-
return
|
|
96
|
+
return yield(try)
|
|
66
97
|
rescue *exception_list => e
|
|
67
98
|
raise unless retriable_exception?(e, on, exception_list, retry_if)
|
|
68
99
|
|
|
69
|
-
|
|
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)
|
|
70
104
|
call_on_retry(on_retry, e, try, elapsed_time.call, interval)
|
|
71
105
|
|
|
72
|
-
|
|
106
|
+
elapsed_interval = sleep_disabled == true ? 0 : interval
|
|
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
|
|
73
114
|
|
|
74
115
|
sleep interval if sleep_disabled != true
|
|
75
116
|
end
|
|
76
117
|
end
|
|
77
118
|
end
|
|
78
119
|
|
|
79
|
-
def
|
|
80
|
-
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)
|
|
130
|
+
|
|
131
|
+
RetryPlan.new(max_tries, ->(index) { index < max_tries - 1 ? provider.call(index) : nil })
|
|
132
|
+
end
|
|
81
133
|
|
|
134
|
+
def interval_provider(local_config)
|
|
82
135
|
ExponentialBackoff.new(
|
|
83
|
-
tries: tries - 1,
|
|
84
136
|
base_interval: local_config.base_interval,
|
|
85
137
|
multiplier: local_config.multiplier,
|
|
86
138
|
max_interval: local_config.max_interval,
|
|
87
139
|
rand_factor: local_config.rand_factor,
|
|
88
|
-
).
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def call_with_timeout(timeout, try)
|
|
92
|
-
return Timeout.timeout(timeout) { yield(try) } if timeout
|
|
93
|
-
|
|
94
|
-
yield(try)
|
|
140
|
+
).interval_provider
|
|
95
141
|
end
|
|
96
142
|
|
|
97
143
|
def call_on_retry(on_retry, exception, try, elapsed_time, interval)
|
|
@@ -100,11 +146,24 @@ module Retriable
|
|
|
100
146
|
on_retry.call(exception, try, elapsed_time, interval)
|
|
101
147
|
end
|
|
102
148
|
|
|
103
|
-
def
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
153
|
+
|
|
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?
|
|
106
165
|
|
|
107
|
-
(elapsed_time + interval)
|
|
166
|
+
:max_elapsed_time if (elapsed_time + interval) > max_elapsed_time
|
|
108
167
|
end
|
|
109
168
|
|
|
110
169
|
# When `on` is a Hash, we need to verify the exception matches a pattern.
|
|
@@ -128,13 +187,97 @@ module Retriable
|
|
|
128
187
|
end
|
|
129
188
|
end
|
|
130
189
|
|
|
190
|
+
def validate_override_options(opts)
|
|
191
|
+
opts.each_key do |k|
|
|
192
|
+
raise ArgumentError, "#{k} is not a valid option" unless Config::ATTRIBUTES.include?(k)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
return unless opts.key?(:contexts)
|
|
196
|
+
|
|
197
|
+
contexts = opts[:contexts]
|
|
198
|
+
return if contexts.nil?
|
|
199
|
+
|
|
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)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
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
|
|
212
|
+
|
|
213
|
+
context_attributes = Config::ATTRIBUTES - [:contexts]
|
|
214
|
+
context_options.each_key do |k|
|
|
215
|
+
raise ArgumentError, "#{k} is not a valid option" unless context_attributes.include?(k)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def apply_override_options(options, overrides)
|
|
220
|
+
return options unless overrides
|
|
221
|
+
|
|
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
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def available_contexts
|
|
236
|
+
config_contexts.merge(override_contexts)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def context_options_for(context_key, options)
|
|
240
|
+
context_options = config_contexts.fetch(context_key, {})
|
|
241
|
+
context_options = {} unless context_options.is_a?(Hash)
|
|
242
|
+
context_options = merge_layer(context_options, options)
|
|
243
|
+
|
|
244
|
+
override_context_options = override_contexts[context_key]
|
|
245
|
+
return context_options unless override_context_options.is_a?(Hash)
|
|
246
|
+
|
|
247
|
+
apply_override_options(context_options, override_context_options)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def config_contexts
|
|
251
|
+
config.contexts.is_a?(Hash) ? config.contexts : {}
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def override_contexts
|
|
255
|
+
override_config = current_override
|
|
256
|
+
contexts = override_config && override_config[:contexts]
|
|
257
|
+
contexts.is_a?(Hash) ? contexts : {}
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def current_override
|
|
261
|
+
Thread.current.thread_variable_get(OVERRIDE_THREAD_KEY)
|
|
262
|
+
end
|
|
263
|
+
|
|
131
264
|
private_class_method(
|
|
265
|
+
:validate_override_options,
|
|
266
|
+
:validate_context_override_options,
|
|
132
267
|
:execute_tries,
|
|
133
|
-
:
|
|
134
|
-
:
|
|
268
|
+
:retry_plan,
|
|
269
|
+
:interval_provider,
|
|
135
270
|
:call_on_retry,
|
|
136
|
-
:
|
|
271
|
+
:call_on_give_up,
|
|
272
|
+
:retry_stop_reason,
|
|
137
273
|
:retriable_exception?,
|
|
138
274
|
:hash_exception_match?,
|
|
275
|
+
:apply_override_options,
|
|
276
|
+
:merge_layer,
|
|
277
|
+
:available_contexts,
|
|
278
|
+
:context_options_for,
|
|
279
|
+
:config_contexts,
|
|
280
|
+
:override_contexts,
|
|
281
|
+
:current_override,
|
|
139
282
|
)
|
|
140
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
|