retriable 3.5.0 → 3.8.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/CHANGELOG.md +24 -0
- data/README.md +131 -99
- data/docs/testing.md +212 -0
- data/lib/retriable/config.rb +103 -0
- data/lib/retriable/exponential_backoff.rb +31 -5
- data/lib/retriable/validation.rb +91 -0
- data/lib/retriable/version.rb +1 -1
- data/lib/retriable.rb +72 -32
- data/spec/config_spec.rb +187 -0
- data/spec/exponential_backoff_spec.rb +45 -26
- data/spec/retriable_spec.rb +483 -81
- data/spec/spec_helper.rb +13 -0
- metadata +3 -1
data/lib/retriable/config.rb
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
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
|
|
@@ -15,6 +18,19 @@ module Retriable
|
|
|
15
18
|
contexts
|
|
16
19
|
]).freeze
|
|
17
20
|
|
|
21
|
+
TIMEOUT_DEPRECATION_MESSAGE = "NOTE: Retriable's `timeout:` option is deprecated and will be removed in " \
|
|
22
|
+
"Retriable 4.0. It is a thin wrapper around `Timeout.timeout`, which " \
|
|
23
|
+
"can interrupt execution at arbitrary lines and corrupt internal state " \
|
|
24
|
+
"in libraries that are not interrupt-safe. Prefer your library's native " \
|
|
25
|
+
"timeout, or wrap your block in `Timeout.timeout(...)` yourself."
|
|
26
|
+
private_constant :TIMEOUT_DEPRECATION_MESSAGE
|
|
27
|
+
|
|
28
|
+
@timeout_deprecation_warned = false
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
attr_accessor :timeout_deprecation_warned
|
|
32
|
+
end
|
|
33
|
+
|
|
18
34
|
attr_accessor(*ATTRIBUTES)
|
|
19
35
|
|
|
20
36
|
def initialize(opts = {})
|
|
@@ -39,6 +55,8 @@ module Retriable
|
|
|
39
55
|
|
|
40
56
|
instance_variable_set(:"@#{k}", v)
|
|
41
57
|
end
|
|
58
|
+
|
|
59
|
+
validate!
|
|
42
60
|
end
|
|
43
61
|
|
|
44
62
|
def to_h
|
|
@@ -46,5 +64,90 @@ module Retriable
|
|
|
46
64
|
hash[key] = public_send(key)
|
|
47
65
|
end
|
|
48
66
|
end
|
|
67
|
+
|
|
68
|
+
def validate!
|
|
69
|
+
warn_timeout_deprecation
|
|
70
|
+
validate_optional_non_negative_number(:timeout, timeout)
|
|
71
|
+
validate_on(on)
|
|
72
|
+
validate_intervals
|
|
73
|
+
if unbounded_tries?(tries)
|
|
74
|
+
validate_unbounded_tries
|
|
75
|
+
else
|
|
76
|
+
validate_optional_non_negative_number(:max_elapsed_time, max_elapsed_time)
|
|
77
|
+
return if intervals
|
|
78
|
+
|
|
79
|
+
validate_positive_integer(:tries, tries)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
validate_backoff_options
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# Emits the `timeout:` deprecation notice at most once per process.
|
|
88
|
+
#
|
|
89
|
+
# On Rubies that support `Kernel#warn(category: :deprecated)` (2.7+), the
|
|
90
|
+
# notice is emitted under the `:deprecated` category, so callers can use the
|
|
91
|
+
# standard controls (`Warning[:deprecated] = false`, `-W:no-deprecated`,
|
|
92
|
+
# `Warning.warn` override) to silence it. On older Rubies the kwarg is not
|
|
93
|
+
# available and we fall back to plain `Kernel.warn`.
|
|
94
|
+
#
|
|
95
|
+
# When the warning is suppressed (either because `Warning[:deprecated]` is
|
|
96
|
+
# false or the runtime has otherwise muted the category), we deliberately
|
|
97
|
+
# leave the once-per-process flag unset so a future call with the category
|
|
98
|
+
# re-enabled still surfaces the notice.
|
|
99
|
+
def warn_timeout_deprecation
|
|
100
|
+
return if timeout.nil?
|
|
101
|
+
return if self.class.timeout_deprecation_warned
|
|
102
|
+
|
|
103
|
+
category_supported = deprecated_warning_category_supported?
|
|
104
|
+
return if category_supported && !deprecated_warnings_enabled?
|
|
105
|
+
|
|
106
|
+
self.class.timeout_deprecation_warned = true
|
|
107
|
+
if category_supported
|
|
108
|
+
Kernel.warn(TIMEOUT_DEPRECATION_MESSAGE, category: :deprecated)
|
|
109
|
+
else
|
|
110
|
+
Kernel.warn(TIMEOUT_DEPRECATION_MESSAGE)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def deprecated_warning_category_supported?
|
|
115
|
+
defined?(Warning) && Kernel.method(:warn).parameters.any? { |type, name| type == :key && name == :category }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def deprecated_warnings_enabled?
|
|
119
|
+
return true unless defined?(Warning) && Warning.respond_to?(:[])
|
|
120
|
+
|
|
121
|
+
Warning[:deprecated]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def validate_backoff_options
|
|
125
|
+
validate_non_negative_number(:base_interval, base_interval)
|
|
126
|
+
validate_non_negative_number(:multiplier, multiplier)
|
|
127
|
+
validate_non_negative_number(:max_interval, max_interval)
|
|
128
|
+
validate_rand_factor
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def validate_unbounded_tries
|
|
132
|
+
if intervals
|
|
133
|
+
raise ArgumentError,
|
|
134
|
+
"intervals cannot be used with tries: Float::INFINITY"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
unless finite_number?(max_elapsed_time)
|
|
138
|
+
raise ArgumentError,
|
|
139
|
+
"max_elapsed_time must be a finite number when tries is Float::INFINITY"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
validate_non_negative_number(:max_elapsed_time, max_elapsed_time)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def validate_intervals
|
|
146
|
+
return if intervals.nil?
|
|
147
|
+
raise ArgumentError, "intervals must be an Array" unless intervals.is_a?(Array)
|
|
148
|
+
return if intervals.all? { |interval| finite_number?(interval) && interval >= 0 }
|
|
149
|
+
|
|
150
|
+
raise ArgumentError, "intervals must contain only non-negative numbers"
|
|
151
|
+
end
|
|
49
152
|
end
|
|
50
153
|
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
|
|
@@ -24,20 +28,42 @@ module Retriable
|
|
|
24
28
|
|
|
25
29
|
instance_variable_set(:"@#{k}", v)
|
|
26
30
|
end
|
|
31
|
+
|
|
32
|
+
validate!
|
|
27
33
|
end
|
|
28
34
|
|
|
29
35
|
def intervals
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
provider = interval_provider
|
|
37
|
+
Array.new(tries) { |iteration| provider.call(iteration) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def interval_provider
|
|
41
|
+
raw_interval = base_interval
|
|
33
42
|
|
|
34
|
-
|
|
43
|
+
lambda do |_iteration|
|
|
44
|
+
interval = [raw_interval, max_interval].min
|
|
45
|
+
raw_interval = next_raw_interval(raw_interval)
|
|
35
46
|
|
|
36
|
-
|
|
47
|
+
rand_factor.zero? ? interval : randomize(interval)
|
|
48
|
+
end
|
|
37
49
|
end
|
|
38
50
|
|
|
39
51
|
private
|
|
40
52
|
|
|
53
|
+
def validate!
|
|
54
|
+
validate_non_negative_integer(:tries, tries)
|
|
55
|
+
validate_non_negative_number(:base_interval, base_interval)
|
|
56
|
+
validate_non_negative_number(:multiplier, multiplier)
|
|
57
|
+
validate_non_negative_number(:max_interval, max_interval)
|
|
58
|
+
validate_rand_factor
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def next_raw_interval(raw_interval)
|
|
62
|
+
return max_interval if multiplier >= 1 && raw_interval >= max_interval
|
|
63
|
+
|
|
64
|
+
raw_interval * multiplier
|
|
65
|
+
end
|
|
66
|
+
|
|
41
67
|
def randomize(interval)
|
|
42
68
|
delta = rand_factor * interval.to_f
|
|
43
69
|
min = interval - delta
|
|
@@ -0,0 +1,91 @@
|
|
|
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_rand_factor
|
|
32
|
+
return if finite_number?(rand_factor) && rand_factor >= 0 && rand_factor <= 1
|
|
33
|
+
|
|
34
|
+
raise ArgumentError, "rand_factor must be between 0 and 1"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def finite_number?(value)
|
|
38
|
+
value.is_a?(Numeric) && value.to_f.finite?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def unbounded_tries?(value)
|
|
42
|
+
value.is_a?(Numeric) && value.respond_to?(:infinite?) && value.infinite? == 1
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
module_function :unbounded_tries?
|
|
46
|
+
|
|
47
|
+
# Validates an `on:` value. Acceptable shapes:
|
|
48
|
+
# - a Class that descends from Exception
|
|
49
|
+
# - an Array whose elements are Classes that descend from Exception
|
|
50
|
+
# - a Hash whose keys are such Classes and whose values are nil,
|
|
51
|
+
# a Regexp, or an Array of Regexps
|
|
52
|
+
#
|
|
53
|
+
# Without this validation, callers can pass values like `Object` or
|
|
54
|
+
# `Kernel` and silently retry process-critical exceptions such as
|
|
55
|
+
# SystemExit and Interrupt, because every Exception's ancestor chain
|
|
56
|
+
# includes both. Hash values that are not Regexps (e.g. plain Strings)
|
|
57
|
+
# also silently fail to match in #hash_exception_match?, so we require
|
|
58
|
+
# Regexp values explicitly.
|
|
59
|
+
def validate_on(value)
|
|
60
|
+
case value
|
|
61
|
+
when Hash
|
|
62
|
+
value.each do |klass, pattern|
|
|
63
|
+
validate_on_class(klass)
|
|
64
|
+
validate_on_hash_value(klass, pattern)
|
|
65
|
+
end
|
|
66
|
+
when Array
|
|
67
|
+
value.each { |klass| validate_on_class(klass) }
|
|
68
|
+
else
|
|
69
|
+
validate_on_class(value)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def validate_on_class(klass)
|
|
74
|
+
return if klass.is_a?(Class) && klass <= Exception
|
|
75
|
+
|
|
76
|
+
raise ArgumentError, "on must be an Exception class or a collection of Exception classes, got #{klass.inspect}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def validate_on_hash_value(klass, pattern)
|
|
80
|
+
return if pattern.nil?
|
|
81
|
+
return if pattern.is_a?(Regexp)
|
|
82
|
+
# Ruby 2.3 does not support Enumerable#all?(pattern).
|
|
83
|
+
# rubocop:disable Style/PredicateWithKind
|
|
84
|
+
return if pattern.is_a?(Array) && pattern.all? { |p| p.is_a?(Regexp) }
|
|
85
|
+
# rubocop:enable Style/PredicateWithKind
|
|
86
|
+
|
|
87
|
+
raise ArgumentError,
|
|
88
|
+
"on[#{klass}] must be nil, a Regexp, or an Array of Regexps, got #{pattern.inspect}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/retriable/version.rb
CHANGED
data/lib/retriable.rb
CHANGED
|
@@ -6,6 +6,16 @@ require_relative "retriable/exponential_backoff"
|
|
|
6
6
|
require_relative "retriable/version"
|
|
7
7
|
|
|
8
8
|
module Retriable
|
|
9
|
+
# Thread-local storage key for the active #with_override block.
|
|
10
|
+
# We deliberately use Thread#thread_variable_set/get (true thread-local)
|
|
11
|
+
# rather than Thread.current[] (fiber-local) so that fibers within a thread
|
|
12
|
+
# share the same override. Changing this to Thread.current[] would silently
|
|
13
|
+
# break callers that use fiber-based concurrency.
|
|
14
|
+
OVERRIDE_THREAD_KEY = :retriable_override
|
|
15
|
+
|
|
16
|
+
RetryPlan = Struct.new(:max_tries, :interval_for)
|
|
17
|
+
private_constant :RetryPlan
|
|
18
|
+
|
|
9
19
|
module_function
|
|
10
20
|
|
|
11
21
|
def configure
|
|
@@ -16,15 +26,19 @@ module Retriable
|
|
|
16
26
|
@config ||= Config.new
|
|
17
27
|
end
|
|
18
28
|
|
|
19
|
-
def
|
|
20
|
-
raise ArgumentError, "empty override options are not allowed
|
|
29
|
+
def with_override(opts = {})
|
|
30
|
+
raise ArgumentError, "empty override options are not allowed" if opts.empty?
|
|
31
|
+
raise ArgumentError, "with_override requires a block" unless block_given?
|
|
21
32
|
|
|
22
33
|
validate_override_options(opts)
|
|
23
|
-
@override_config = opts
|
|
24
|
-
end
|
|
25
34
|
|
|
26
|
-
|
|
27
|
-
|
|
35
|
+
previous = Thread.current.thread_variable_get(OVERRIDE_THREAD_KEY)
|
|
36
|
+
Thread.current.thread_variable_set(OVERRIDE_THREAD_KEY, opts)
|
|
37
|
+
begin
|
|
38
|
+
yield
|
|
39
|
+
ensure
|
|
40
|
+
Thread.current.thread_variable_set(OVERRIDE_THREAD_KEY, previous)
|
|
41
|
+
end
|
|
28
42
|
end
|
|
29
43
|
|
|
30
44
|
def with_context(context_key, options = {}, &block)
|
|
@@ -41,14 +55,17 @@ module Retriable
|
|
|
41
55
|
end
|
|
42
56
|
|
|
43
57
|
def retriable(opts = {}, &block)
|
|
44
|
-
|
|
58
|
+
override_config = current_override
|
|
59
|
+
local_config = if opts.empty? && !override_config
|
|
45
60
|
config
|
|
46
61
|
else
|
|
47
|
-
Config.new(apply_override_options(config.to_h.merge(opts),
|
|
62
|
+
Config.new(apply_override_options(config.to_h.merge(opts), override_config))
|
|
48
63
|
end
|
|
49
64
|
|
|
50
|
-
|
|
51
|
-
|
|
65
|
+
# Config is mutable through `configure`, so validate again immediately before use.
|
|
66
|
+
local_config.validate!
|
|
67
|
+
|
|
68
|
+
plan = retry_plan(local_config)
|
|
52
69
|
timeout = local_config.timeout
|
|
53
70
|
on = local_config.on
|
|
54
71
|
retry_if = local_config.retry_if
|
|
@@ -61,10 +78,8 @@ module Retriable
|
|
|
61
78
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
62
79
|
elapsed_time = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time }
|
|
63
80
|
|
|
64
|
-
tries = intervals.size + 1
|
|
65
|
-
|
|
66
81
|
execute_tries(
|
|
67
|
-
|
|
82
|
+
max_tries: plan.max_tries, interval_for: plan.interval_for, timeout: timeout,
|
|
68
83
|
exception_list: exception_list, on: on, retry_if: retry_if, on_retry: on_retry,
|
|
69
84
|
elapsed_time: elapsed_time, max_elapsed_time: max_elapsed_time,
|
|
70
85
|
sleep_disabled: sleep_disabled, &block
|
|
@@ -72,38 +87,49 @@ module Retriable
|
|
|
72
87
|
end
|
|
73
88
|
|
|
74
89
|
def execute_tries( # rubocop:disable Metrics/ParameterLists
|
|
75
|
-
|
|
90
|
+
max_tries:, interval_for:, timeout:, exception_list:,
|
|
76
91
|
on:, retry_if:, on_retry:, elapsed_time:, max_elapsed_time:, sleep_disabled:, &block
|
|
77
92
|
)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
93
|
+
try = 0
|
|
94
|
+
loop do
|
|
95
|
+
try += 1
|
|
81
96
|
begin
|
|
82
97
|
return call_with_timeout(timeout, try, &block)
|
|
83
98
|
rescue *exception_list => e
|
|
84
99
|
raise unless retriable_exception?(e, on, exception_list, retry_if)
|
|
85
100
|
|
|
86
|
-
interval =
|
|
101
|
+
interval = interval_for.call(try - 1)
|
|
87
102
|
call_on_retry(on_retry, e, try, elapsed_time.call, interval)
|
|
88
103
|
|
|
89
104
|
elapsed_interval = sleep_disabled == true ? 0 : interval
|
|
90
|
-
raise unless can_retry?(try,
|
|
105
|
+
raise unless can_retry?(try, max_tries, elapsed_time.call, elapsed_interval, max_elapsed_time)
|
|
91
106
|
|
|
92
107
|
sleep interval if sleep_disabled != true
|
|
93
108
|
end
|
|
94
109
|
end
|
|
95
110
|
end
|
|
96
111
|
|
|
97
|
-
def
|
|
98
|
-
return
|
|
112
|
+
def retry_plan(local_config)
|
|
113
|
+
return RetryPlan.new(nil, interval_provider(local_config)) if Validation.unbounded_tries?(local_config.tries)
|
|
114
|
+
|
|
115
|
+
if local_config.intervals
|
|
116
|
+
intervals = local_config.intervals
|
|
117
|
+
return RetryPlan.new(intervals.size + 1, ->(index) { intervals[index] })
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
max_tries = local_config.tries
|
|
121
|
+
provider = interval_provider(local_config)
|
|
122
|
+
|
|
123
|
+
RetryPlan.new(max_tries, ->(index) { index < max_tries - 1 ? provider.call(index) : nil })
|
|
124
|
+
end
|
|
99
125
|
|
|
126
|
+
def interval_provider(local_config)
|
|
100
127
|
ExponentialBackoff.new(
|
|
101
|
-
tries: tries - 1,
|
|
102
128
|
base_interval: local_config.base_interval,
|
|
103
129
|
multiplier: local_config.multiplier,
|
|
104
130
|
max_interval: local_config.max_interval,
|
|
105
131
|
rand_factor: local_config.rand_factor,
|
|
106
|
-
).
|
|
132
|
+
).interval_provider
|
|
107
133
|
end
|
|
108
134
|
|
|
109
135
|
def call_with_timeout(timeout, try)
|
|
@@ -118,8 +144,8 @@ module Retriable
|
|
|
118
144
|
on_retry.call(exception, try, elapsed_time, interval)
|
|
119
145
|
end
|
|
120
146
|
|
|
121
|
-
def can_retry?(try,
|
|
122
|
-
return false
|
|
147
|
+
def can_retry?(try, max_tries, elapsed_time, interval, max_elapsed_time)
|
|
148
|
+
return false if max_tries && try >= max_tries
|
|
123
149
|
return true if max_elapsed_time.nil?
|
|
124
150
|
|
|
125
151
|
(elapsed_time + interval) <= max_elapsed_time
|
|
@@ -151,16 +177,23 @@ module Retriable
|
|
|
151
177
|
raise ArgumentError, "#{k} is not a valid option" unless Config::ATTRIBUTES.include?(k)
|
|
152
178
|
end
|
|
153
179
|
|
|
180
|
+
return unless opts.key?(:contexts)
|
|
181
|
+
|
|
154
182
|
contexts = opts[:contexts]
|
|
155
|
-
return
|
|
183
|
+
return if contexts.nil?
|
|
184
|
+
|
|
185
|
+
raise ArgumentError, "contexts must be a Hash or nil, got #{contexts.inspect}" unless contexts.is_a?(Hash)
|
|
156
186
|
|
|
157
|
-
contexts.
|
|
158
|
-
validate_context_override_options(context_options)
|
|
187
|
+
contexts.each do |context_key, context_options|
|
|
188
|
+
validate_context_override_options(context_key, context_options)
|
|
159
189
|
end
|
|
160
190
|
end
|
|
161
191
|
|
|
162
|
-
def validate_context_override_options(context_options)
|
|
163
|
-
|
|
192
|
+
def validate_context_override_options(context_key, context_options)
|
|
193
|
+
unless context_options.is_a?(Hash)
|
|
194
|
+
raise ArgumentError,
|
|
195
|
+
"contexts[#{context_key.inspect}] must be a Hash, got #{context_options.inspect}"
|
|
196
|
+
end
|
|
164
197
|
|
|
165
198
|
context_attributes = Config::ATTRIBUTES - [:contexts]
|
|
166
199
|
context_options.each_key do |k|
|
|
@@ -196,15 +229,21 @@ module Retriable
|
|
|
196
229
|
end
|
|
197
230
|
|
|
198
231
|
def override_contexts
|
|
199
|
-
|
|
232
|
+
override_config = current_override
|
|
233
|
+
contexts = override_config && override_config[:contexts]
|
|
200
234
|
contexts.is_a?(Hash) ? contexts : {}
|
|
201
235
|
end
|
|
202
236
|
|
|
237
|
+
def current_override
|
|
238
|
+
Thread.current.thread_variable_get(OVERRIDE_THREAD_KEY)
|
|
239
|
+
end
|
|
240
|
+
|
|
203
241
|
private_class_method(
|
|
204
242
|
:validate_override_options,
|
|
205
243
|
:validate_context_override_options,
|
|
206
244
|
:execute_tries,
|
|
207
|
-
:
|
|
245
|
+
:retry_plan,
|
|
246
|
+
:interval_provider,
|
|
208
247
|
:call_with_timeout,
|
|
209
248
|
:call_on_retry,
|
|
210
249
|
:can_retry?,
|
|
@@ -215,5 +254,6 @@ module Retriable
|
|
|
215
254
|
:context_options_for,
|
|
216
255
|
:config_contexts,
|
|
217
256
|
:override_contexts,
|
|
257
|
+
:current_override,
|
|
218
258
|
)
|
|
219
259
|
end
|
data/spec/config_spec.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
3
5
|
describe Retriable::Config do
|
|
4
6
|
let(:default_config) { described_class.new }
|
|
5
7
|
|
|
@@ -56,4 +58,189 @@ describe Retriable::Config do
|
|
|
56
58
|
it "raises errors on invalid configuration" do
|
|
57
59
|
expect { described_class.new(does_not_exist: 123) }.to raise_error(ArgumentError, /not a valid option/)
|
|
58
60
|
end
|
|
61
|
+
|
|
62
|
+
it "raises errors on invalid timing configuration" do
|
|
63
|
+
expect { described_class.new(rand_factor: 1.1) }.to raise_error(ArgumentError, /rand_factor/)
|
|
64
|
+
expect do
|
|
65
|
+
expect { described_class.new(timeout: -1) }.to raise_error(ArgumentError, /timeout/)
|
|
66
|
+
end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
context "timeout deprecation" do
|
|
70
|
+
it "warns when timeout is configured" do
|
|
71
|
+
expect do
|
|
72
|
+
described_class.new(timeout: 5)
|
|
73
|
+
end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "warns when timeout is set before validation" do
|
|
77
|
+
config = described_class.new
|
|
78
|
+
config.timeout = 5
|
|
79
|
+
|
|
80
|
+
expect do
|
|
81
|
+
config.validate!
|
|
82
|
+
end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "does not warn when timeout is nil" do
|
|
86
|
+
expect do
|
|
87
|
+
described_class.new(timeout: nil)
|
|
88
|
+
end.not_to output.to_stderr
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it "does not warn when timeout is omitted" do
|
|
92
|
+
expect do
|
|
93
|
+
described_class.new
|
|
94
|
+
end.not_to output.to_stderr
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "warns at most once per process" do
|
|
98
|
+
original_stderr = $stderr
|
|
99
|
+
stderr = StringIO.new
|
|
100
|
+
begin
|
|
101
|
+
$stderr = stderr
|
|
102
|
+
|
|
103
|
+
described_class.new(timeout: 5)
|
|
104
|
+
described_class.new(timeout: 5)
|
|
105
|
+
|
|
106
|
+
config = described_class.new
|
|
107
|
+
config.timeout = 5
|
|
108
|
+
config.validate!
|
|
109
|
+
ensure
|
|
110
|
+
$stderr = original_stderr
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
expect(stderr.string.scan("timeout:` option is deprecated").size).to eq(1)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it "emits the warning under the :deprecated category when supported", if: WARN_CATEGORY_SUPPORTED do
|
|
117
|
+
captured = []
|
|
118
|
+
allow(Warning).to receive(:warn) do |message, category: nil|
|
|
119
|
+
captured << [message, category]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
described_class.new(timeout: 5)
|
|
123
|
+
|
|
124
|
+
expect(captured.size).to eq(1)
|
|
125
|
+
message, category = captured.first
|
|
126
|
+
expect(message).to match(/timeout.*deprecated.*Retriable 4\.0/i)
|
|
127
|
+
expect(category).to eq(:deprecated)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it "is silenced by Warning[:deprecated] = false", if: WARN_CATEGORY_SUPPORTED do
|
|
131
|
+
original = Warning[:deprecated]
|
|
132
|
+
begin
|
|
133
|
+
Warning[:deprecated] = false
|
|
134
|
+
expect do
|
|
135
|
+
described_class.new(timeout: 5)
|
|
136
|
+
end.not_to output.to_stderr
|
|
137
|
+
ensure
|
|
138
|
+
Warning[:deprecated] = original
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it "remains armed when silenced via Warning[:deprecated]", if: WARN_CATEGORY_SUPPORTED do
|
|
143
|
+
original = Warning[:deprecated]
|
|
144
|
+
begin
|
|
145
|
+
Warning[:deprecated] = false
|
|
146
|
+
described_class.new(timeout: 5)
|
|
147
|
+
ensure
|
|
148
|
+
Warning[:deprecated] = original
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
expect do
|
|
152
|
+
described_class.new(timeout: 5)
|
|
153
|
+
end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
it "raises errors when intervals is not an array" do
|
|
158
|
+
expect { described_class.new(intervals: "1") }.to raise_error(ArgumentError, /intervals must be an Array/)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it "requires a finite max_elapsed_time when tries is Float::INFINITY" do
|
|
162
|
+
expect { described_class.new(tries: Float::INFINITY, max_elapsed_time: nil) }
|
|
163
|
+
.to raise_error(ArgumentError, /max_elapsed_time must be a finite number/)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it "rejects intervals combined with tries: Float::INFINITY" do
|
|
167
|
+
expect do
|
|
168
|
+
described_class.new(
|
|
169
|
+
tries: Float::INFINITY,
|
|
170
|
+
max_elapsed_time: 60,
|
|
171
|
+
intervals: [0.1, 0.2],
|
|
172
|
+
)
|
|
173
|
+
end.to raise_error(ArgumentError, /intervals cannot be used with tries: Float::INFINITY/)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it "accepts tries: Float::INFINITY with a finite max_elapsed_time" do
|
|
177
|
+
expect { described_class.new(tries: Float::INFINITY, max_elapsed_time: 60) }
|
|
178
|
+
.not_to raise_error
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
context "on: option validation" do
|
|
182
|
+
it "accepts a single Exception subclass" do
|
|
183
|
+
expect { described_class.new(on: StandardError) }.not_to raise_error
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it "accepts Exception itself" do
|
|
187
|
+
expect { described_class.new(on: Exception) }.not_to raise_error
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
it "accepts an array of Exception subclasses" do
|
|
191
|
+
expect { described_class.new(on: [StandardError, RuntimeError]) }.not_to raise_error
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
it "accepts a hash with nil pattern values" do
|
|
195
|
+
expect { described_class.new(on: { StandardError => nil }) }.not_to raise_error
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
it "accepts a hash with Regexp pattern values" do
|
|
199
|
+
expect { described_class.new(on: { StandardError => /boom/ }) }.not_to raise_error
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
it "accepts a hash with Array-of-Regexp pattern values" do
|
|
203
|
+
expect { described_class.new(on: { StandardError => [/a/, /b/] }) }.not_to raise_error
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
it "rejects Object as on:" do
|
|
207
|
+
expect { described_class.new(on: Object) }
|
|
208
|
+
.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
it "rejects Kernel as on:" do
|
|
212
|
+
expect { described_class.new(on: Kernel) }
|
|
213
|
+
.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
it "rejects an array containing a non-Exception class" do
|
|
217
|
+
expect { described_class.new(on: [StandardError, Kernel]) }
|
|
218
|
+
.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
it "rejects a hash key that is not an Exception class" do
|
|
222
|
+
expect { described_class.new(on: { Kernel => nil }) }
|
|
223
|
+
.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
it "rejects a hash value that is a String" do
|
|
227
|
+
expect { described_class.new(on: { StandardError => "boom" }) }
|
|
228
|
+
.to raise_error(ArgumentError, /on\[StandardError\] must be nil, a Regexp, or an Array of Regexps/)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
it "rejects a hash value that is an Array containing a non-Regexp" do
|
|
232
|
+
expect { described_class.new(on: { StandardError => [/a/, "b"] }) }
|
|
233
|
+
.to raise_error(ArgumentError, /on\[StandardError\] must be nil, a Regexp, or an Array of Regexps/)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
it "rejects a string passed as on:" do
|
|
237
|
+
expect { described_class.new(on: "StandardError") }
|
|
238
|
+
.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
it "validates on: even when intervals is provided" do
|
|
242
|
+
expect { described_class.new(intervals: [0.1], on: Object) }
|
|
243
|
+
.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
59
246
|
end
|