retriable 3.4.1 → 4.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.
- checksums.yaml +4 -4
- data/.github/workflows/main.yml +43 -11
- data/.hound.yml +1 -1
- data/.rubocop.yml +4 -1
- data/CHANGELOG.md +77 -0
- data/Gemfile +4 -1
- data/README.md +204 -95
- data/docs/testing.md +212 -0
- data/lib/retriable/config.rb +56 -4
- data/lib/retriable/core_ext/kernel.rb +4 -4
- data/lib/retriable/exponential_backoff.rb +31 -5
- data/lib/retriable/validation.rb +95 -0
- data/lib/retriable/version.rb +1 -1
- data/lib/retriable.rb +180 -40
- data/retriable.gemspec +2 -7
- data/sig/retriable.rbs +29 -1
- data/spec/config_spec.rb +128 -4
- data/spec/exponential_backoff_spec.rb +45 -26
- data/spec/retriable_spec.rb +889 -4
- data/spec/spec_helper.rb +3 -1
- metadata +8 -52
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
|
|
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)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def with_context(context_key, options = {}, &)
|
|
44
|
+
contexts = available_contexts
|
|
45
|
+
|
|
46
|
+
if !contexts.key?(context_key)
|
|
21
47
|
raise ArgumentError,
|
|
22
|
-
"#{context_key} not found in Retriable
|
|
48
|
+
"#{context_key} not found in Retriable contexts (including overrides). Available contexts: #{contexts.keys}"
|
|
23
49
|
end
|
|
24
50
|
|
|
25
51
|
return unless block_given?
|
|
26
52
|
|
|
27
|
-
retriable(
|
|
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,64 @@ 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
|
-
interval =
|
|
100
|
+
interval = interval_for.call(try - 1)
|
|
70
101
|
call_on_retry(on_retry, e, try, elapsed_time.call, interval)
|
|
71
102
|
|
|
72
|
-
|
|
103
|
+
elapsed_interval = sleep_disabled == true ? 0 : interval
|
|
104
|
+
# Snapshot elapsed_time once so the stop check and on_give_up see the same value.
|
|
105
|
+
current_elapsed_time = elapsed_time.call
|
|
106
|
+
stop_reason = retry_stop_reason(try, max_tries, current_elapsed_time, elapsed_interval, max_elapsed_time)
|
|
107
|
+
if stop_reason
|
|
108
|
+
call_on_give_up(on_give_up, e, try, current_elapsed_time, interval, stop_reason)
|
|
109
|
+
raise
|
|
110
|
+
end
|
|
73
111
|
|
|
74
112
|
sleep interval if sleep_disabled != true
|
|
75
113
|
end
|
|
76
114
|
end
|
|
77
115
|
end
|
|
78
116
|
|
|
79
|
-
def
|
|
80
|
-
return
|
|
117
|
+
def retry_plan(local_config)
|
|
118
|
+
return RetryPlan.new(nil, interval_provider(local_config)) if Validation.unbounded_tries?(local_config.tries)
|
|
119
|
+
|
|
120
|
+
if local_config.intervals
|
|
121
|
+
intervals = local_config.intervals
|
|
122
|
+
return RetryPlan.new(intervals.size + 1, ->(index) { intervals[index] })
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
max_tries = local_config.tries
|
|
126
|
+
provider = interval_provider(local_config)
|
|
81
127
|
|
|
128
|
+
RetryPlan.new(max_tries, ->(index) { index < max_tries - 1 ? provider.call(index) : nil })
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def interval_provider(local_config)
|
|
82
132
|
ExponentialBackoff.new(
|
|
83
|
-
tries: tries - 1,
|
|
84
133
|
base_interval: local_config.base_interval,
|
|
85
134
|
multiplier: local_config.multiplier,
|
|
86
135
|
max_interval: local_config.max_interval,
|
|
87
136
|
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)
|
|
137
|
+
).interval_provider
|
|
95
138
|
end
|
|
96
139
|
|
|
97
140
|
def call_on_retry(on_retry, exception, try, elapsed_time, interval)
|
|
@@ -100,11 +143,24 @@ module Retriable
|
|
|
100
143
|
on_retry.call(exception, try, elapsed_time, interval)
|
|
101
144
|
end
|
|
102
145
|
|
|
103
|
-
def
|
|
104
|
-
|
|
105
|
-
|
|
146
|
+
def call_on_give_up( # rubocop:disable Metrics/ParameterLists
|
|
147
|
+
on_give_up, exception, try, elapsed_time, interval, reason
|
|
148
|
+
)
|
|
149
|
+
return unless on_give_up
|
|
106
150
|
|
|
107
|
-
(elapsed_time
|
|
151
|
+
on_give_up.call(exception, try, elapsed_time, interval, reason)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# `:tries_exhausted` is checked first, but the two conditions can't both hold
|
|
155
|
+
# on the same try in practice: `retry_plan` returns a nil interval whenever
|
|
156
|
+
# `try >= max_tries`, so `(elapsed_time + interval) > max_elapsed_time` is not
|
|
157
|
+
# evaluable on the exhausted-tries try. The early return guards against that
|
|
158
|
+
# nil and also pins precedence in case the plan ever changes.
|
|
159
|
+
def retry_stop_reason(try, max_tries, elapsed_time, interval, max_elapsed_time)
|
|
160
|
+
return :tries_exhausted if max_tries && try >= max_tries
|
|
161
|
+
return nil if max_elapsed_time.nil?
|
|
162
|
+
|
|
163
|
+
:max_elapsed_time if (elapsed_time + interval) > max_elapsed_time
|
|
108
164
|
end
|
|
109
165
|
|
|
110
166
|
# When `on` is a Hash, we need to verify the exception matches a pattern.
|
|
@@ -128,13 +184,97 @@ module Retriable
|
|
|
128
184
|
end
|
|
129
185
|
end
|
|
130
186
|
|
|
187
|
+
def validate_override_options(opts)
|
|
188
|
+
opts.each_key do |k|
|
|
189
|
+
raise ArgumentError, "#{k} is not a valid option" unless Config::ATTRIBUTES.include?(k)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
return unless opts.key?(:contexts)
|
|
193
|
+
|
|
194
|
+
contexts = opts[:contexts]
|
|
195
|
+
return if contexts.nil?
|
|
196
|
+
|
|
197
|
+
raise ArgumentError, "contexts must be a Hash or nil, got #{contexts.inspect}" unless contexts.is_a?(Hash)
|
|
198
|
+
|
|
199
|
+
contexts.each do |context_key, context_options|
|
|
200
|
+
validate_context_override_options(context_key, context_options)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def validate_context_override_options(context_key, context_options)
|
|
205
|
+
unless context_options.is_a?(Hash)
|
|
206
|
+
raise ArgumentError,
|
|
207
|
+
"contexts[#{context_key.inspect}] must be a Hash, got #{context_options.inspect}"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
context_attributes = Config::ATTRIBUTES - [:contexts]
|
|
211
|
+
context_options.each_key do |k|
|
|
212
|
+
raise ArgumentError, "#{k} is not a valid option" unless context_attributes.include?(k)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def apply_override_options(options, overrides)
|
|
217
|
+
return options unless overrides
|
|
218
|
+
|
|
219
|
+
merge_layer(options, overrides)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Merge a higher-precedence option layer onto a base layer. A higher layer
|
|
223
|
+
# that sets `tries` without `intervals` clears the base layer's inherited
|
|
224
|
+
# `intervals`, so a caller's `tries:` is never silently ignored. When the
|
|
225
|
+
# higher layer supplies its own `intervals`, those win (same-call override).
|
|
226
|
+
def merge_layer(base, higher)
|
|
227
|
+
merged = base.merge(higher)
|
|
228
|
+
merged[:intervals] = nil if higher.key?(:tries) && !higher.key?(:intervals)
|
|
229
|
+
merged
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def available_contexts
|
|
233
|
+
config_contexts.merge(override_contexts)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def context_options_for(context_key, options)
|
|
237
|
+
context_options = config_contexts.fetch(context_key, {})
|
|
238
|
+
context_options = {} unless context_options.is_a?(Hash)
|
|
239
|
+
context_options = merge_layer(context_options, options)
|
|
240
|
+
|
|
241
|
+
override_context_options = override_contexts[context_key]
|
|
242
|
+
return context_options unless override_context_options.is_a?(Hash)
|
|
243
|
+
|
|
244
|
+
apply_override_options(context_options, override_context_options)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def config_contexts
|
|
248
|
+
config.contexts.is_a?(Hash) ? config.contexts : {}
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def override_contexts
|
|
252
|
+
override_config = current_override
|
|
253
|
+
contexts = override_config && override_config[:contexts]
|
|
254
|
+
contexts.is_a?(Hash) ? contexts : {}
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def current_override
|
|
258
|
+
Thread.current.thread_variable_get(OVERRIDE_THREAD_KEY)
|
|
259
|
+
end
|
|
260
|
+
|
|
131
261
|
private_class_method(
|
|
262
|
+
:validate_override_options,
|
|
263
|
+
:validate_context_override_options,
|
|
132
264
|
:execute_tries,
|
|
133
|
-
:
|
|
134
|
-
:
|
|
265
|
+
:retry_plan,
|
|
266
|
+
:interval_provider,
|
|
135
267
|
:call_on_retry,
|
|
136
|
-
:
|
|
268
|
+
:call_on_give_up,
|
|
269
|
+
:retry_stop_reason,
|
|
137
270
|
:retriable_exception?,
|
|
138
271
|
:hash_exception_match?,
|
|
272
|
+
:apply_override_options,
|
|
273
|
+
:merge_layer,
|
|
274
|
+
:available_contexts,
|
|
275
|
+
:context_options_for,
|
|
276
|
+
:config_contexts,
|
|
277
|
+
:override_contexts,
|
|
278
|
+
:current_override,
|
|
139
279
|
)
|
|
140
280
|
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
|
data/sig/retriable.rbs
CHANGED
|
@@ -1,4 +1,32 @@
|
|
|
1
1
|
module Retriable
|
|
2
2
|
VERSION: String
|
|
3
|
-
|
|
3
|
+
OVERRIDE_THREAD_KEY: Symbol
|
|
4
|
+
|
|
5
|
+
def self.configure: () { (Config) -> void } -> void
|
|
6
|
+
def self.config: () -> Config
|
|
7
|
+
def self.with_override: (Hash[Symbol, untyped] options) { () -> untyped } -> untyped
|
|
8
|
+
def self.with_context: (Symbol context_key, ?Hash[Symbol, untyped] options) ?{ (Integer) -> untyped } -> untyped
|
|
9
|
+
def self.retriable: (?Hash[Symbol, untyped] options) { (Integer) -> untyped } -> untyped
|
|
10
|
+
|
|
11
|
+
class Config
|
|
12
|
+
ATTRIBUTES: Array[Symbol]
|
|
13
|
+
|
|
14
|
+
attr_accessor tries: Numeric
|
|
15
|
+
attr_accessor base_interval: Numeric
|
|
16
|
+
attr_accessor max_interval: Numeric
|
|
17
|
+
attr_accessor rand_factor: Numeric
|
|
18
|
+
attr_accessor multiplier: Numeric
|
|
19
|
+
attr_accessor sleep_disabled: bool
|
|
20
|
+
attr_accessor max_elapsed_time: Numeric?
|
|
21
|
+
attr_accessor intervals: Array[Numeric]?
|
|
22
|
+
attr_accessor on: untyped
|
|
23
|
+
attr_accessor retry_if: untyped
|
|
24
|
+
attr_accessor on_retry: untyped
|
|
25
|
+
attr_accessor on_give_up: untyped
|
|
26
|
+
attr_accessor contexts: Hash[Symbol, untyped]
|
|
27
|
+
|
|
28
|
+
def initialize: (?Hash[Symbol, untyped] opts) -> void
|
|
29
|
+
def to_h: () -> Hash[Symbol, untyped]
|
|
30
|
+
def validate!: () -> void
|
|
31
|
+
end
|
|
4
32
|
end
|
data/spec/config_spec.rb
CHANGED
|
@@ -32,10 +32,6 @@ describe Retriable::Config do
|
|
|
32
32
|
expect(default_config.intervals).to be_nil
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
it "timeout defaults to nil" do
|
|
36
|
-
expect(default_config.timeout).to be_nil
|
|
37
|
-
end
|
|
38
|
-
|
|
39
35
|
it "on defaults to [StandardError]" do
|
|
40
36
|
expect(default_config.on).to eq([StandardError])
|
|
41
37
|
end
|
|
@@ -48,6 +44,10 @@ describe Retriable::Config do
|
|
|
48
44
|
expect(default_config.on_retry).to be_nil
|
|
49
45
|
end
|
|
50
46
|
|
|
47
|
+
it "on_give_up handler defaults to nil" do
|
|
48
|
+
expect(default_config.on_give_up).to be_nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
51
|
it "contexts defaults to {}" do
|
|
52
52
|
expect(default_config.contexts).to eq({})
|
|
53
53
|
end
|
|
@@ -56,4 +56,128 @@ describe Retriable::Config do
|
|
|
56
56
|
it "raises errors on invalid configuration" do
|
|
57
57
|
expect { described_class.new(does_not_exist: 123) }.to raise_error(ArgumentError, /not a valid option/)
|
|
58
58
|
end
|
|
59
|
+
|
|
60
|
+
it "rejects timeout as an unknown option" do
|
|
61
|
+
expect { described_class.new(timeout: 5) }.to raise_error(ArgumentError, /not a valid option/)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "raises errors on invalid timing configuration" do
|
|
65
|
+
expect { described_class.new(rand_factor: 1.1) }.to raise_error(ArgumentError, /rand_factor/)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it "raises errors when intervals is not an array" do
|
|
69
|
+
expect { described_class.new(intervals: "1") }.to raise_error(ArgumentError, /intervals must be an Array/)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it "requires a finite max_elapsed_time when tries is Float::INFINITY" do
|
|
73
|
+
expect { described_class.new(tries: Float::INFINITY, max_elapsed_time: nil) }
|
|
74
|
+
.to raise_error(ArgumentError, /max_elapsed_time must be a finite number/)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it "rejects intervals combined with tries: Float::INFINITY" do
|
|
78
|
+
expect do
|
|
79
|
+
described_class.new(
|
|
80
|
+
tries: Float::INFINITY,
|
|
81
|
+
max_elapsed_time: 60,
|
|
82
|
+
intervals: [0.1, 0.2],
|
|
83
|
+
)
|
|
84
|
+
end.to raise_error(ArgumentError, /intervals cannot be used with tries: Float::INFINITY/)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "accepts tries: Float::INFINITY with a finite max_elapsed_time" do
|
|
88
|
+
expect { described_class.new(tries: Float::INFINITY, max_elapsed_time: 60) }
|
|
89
|
+
.not_to raise_error
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
context "on: option validation" do
|
|
93
|
+
it "accepts a single Exception subclass" do
|
|
94
|
+
expect { described_class.new(on: StandardError) }.not_to raise_error
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "accepts Exception itself" do
|
|
98
|
+
expect { described_class.new(on: Exception) }.not_to raise_error
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it "accepts an array of Exception subclasses" do
|
|
102
|
+
expect { described_class.new(on: [StandardError, RuntimeError]) }.not_to raise_error
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "accepts a Set of Exception subclasses" do
|
|
106
|
+
expect { described_class.new(on: Set[StandardError, RuntimeError]) }.not_to raise_error
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it "rejects a Set containing a non-Exception class" do
|
|
110
|
+
expect { described_class.new(on: Set[StandardError, Kernel]) }
|
|
111
|
+
.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it "accepts a hash with nil pattern values" do
|
|
115
|
+
expect { described_class.new(on: { StandardError => nil }) }.not_to raise_error
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it "accepts a hash with Regexp pattern values" do
|
|
119
|
+
expect { described_class.new(on: { StandardError => /boom/ }) }.not_to raise_error
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it "accepts a hash with Array-of-Regexp pattern values" do
|
|
123
|
+
expect { described_class.new(on: { StandardError => [/a/, /b/] }) }.not_to raise_error
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it "rejects Object as on:" do
|
|
127
|
+
expect { described_class.new(on: Object) }
|
|
128
|
+
.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it "rejects Kernel as on:" do
|
|
132
|
+
expect { described_class.new(on: Kernel) }
|
|
133
|
+
.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
it "rejects an array containing a non-Exception class" do
|
|
137
|
+
expect { described_class.new(on: [StandardError, Kernel]) }
|
|
138
|
+
.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it "rejects a hash key that is not an Exception class" do
|
|
142
|
+
expect { described_class.new(on: { Kernel => nil }) }
|
|
143
|
+
.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it "rejects a hash value that is a String" do
|
|
147
|
+
expect { described_class.new(on: { StandardError => "boom" }) }
|
|
148
|
+
.to raise_error(ArgumentError, /on\[StandardError\] must be nil, a Regexp, or an Array of Regexps/)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
it "rejects a hash value that is an Array containing a non-Regexp" do
|
|
152
|
+
expect { described_class.new(on: { StandardError => [/a/, "b"] }) }
|
|
153
|
+
.to raise_error(ArgumentError, /on\[StandardError\] must be nil, a Regexp, or an Array of Regexps/)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it "rejects a string passed as on:" do
|
|
157
|
+
expect { described_class.new(on: "StandardError") }
|
|
158
|
+
.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it "validates on: even when intervals is provided" do
|
|
162
|
+
expect { described_class.new(intervals: [0.1], on: Object) }
|
|
163
|
+
.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
context "callable option validation" do
|
|
168
|
+
%i[retry_if on_retry on_give_up].each do |opt|
|
|
169
|
+
it "accepts a callable for #{opt}" do
|
|
170
|
+
expect { described_class.new(opt => ->(*) {}) }.not_to raise_error
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it "accepts nil and false for #{opt}" do
|
|
174
|
+
expect { described_class.new(opt => nil) }.not_to raise_error
|
|
175
|
+
expect { described_class.new(opt => false) }.not_to raise_error
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
it "rejects a non-callable truthy value for #{opt}" do
|
|
179
|
+
expect { described_class.new(opt => 5) }.to raise_error(ArgumentError, /#{opt}.*#call/)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
59
183
|
end
|
|
@@ -22,17 +22,19 @@ describe Retriable::ExponentialBackoff do
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
it "generates 10 randomized intervals" do
|
|
25
|
-
expect(described_class.new(tries: 9).intervals).to eq(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
25
|
+
expect(described_class.new(tries: 9).intervals).to eq(
|
|
26
|
+
[
|
|
27
|
+
0.5244067512211441,
|
|
28
|
+
0.9113920238761231,
|
|
29
|
+
1.2406087918999114,
|
|
30
|
+
1.7632403621664823,
|
|
31
|
+
2.338001204738311,
|
|
32
|
+
4.350816718580626,
|
|
33
|
+
5.339852157217869,
|
|
34
|
+
11.889873261212443,
|
|
35
|
+
18.756037881636484,
|
|
36
|
+
],
|
|
37
|
+
)
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
it "generates defined number of intervals" do
|
|
@@ -40,19 +42,23 @@ describe Retriable::ExponentialBackoff do
|
|
|
40
42
|
end
|
|
41
43
|
|
|
42
44
|
it "generates intervals with a defined base interval" do
|
|
43
|
-
expect(described_class.new(base_interval: 1).intervals).to eq(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
expect(described_class.new(base_interval: 1).intervals).to eq(
|
|
46
|
+
[
|
|
47
|
+
1.0488135024422882,
|
|
48
|
+
1.8227840477522461,
|
|
49
|
+
2.4812175837998227,
|
|
50
|
+
],
|
|
51
|
+
)
|
|
48
52
|
end
|
|
49
53
|
|
|
50
54
|
it "generates intervals with a defined multiplier" do
|
|
51
|
-
expect(described_class.new(multiplier: 1).intervals).to eq(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
expect(described_class.new(multiplier: 1).intervals).to eq(
|
|
56
|
+
[
|
|
57
|
+
0.5244067512211441,
|
|
58
|
+
0.607594682584082,
|
|
59
|
+
0.5513816852888495,
|
|
60
|
+
],
|
|
61
|
+
)
|
|
56
62
|
end
|
|
57
63
|
|
|
58
64
|
it "generates intervals with a defined max interval" do
|
|
@@ -60,15 +66,28 @@ describe Retriable::ExponentialBackoff do
|
|
|
60
66
|
end
|
|
61
67
|
|
|
62
68
|
it "generates intervals with a defined rand_factor" do
|
|
63
|
-
expect(described_class.new(rand_factor: 0.2).intervals).to eq(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
69
|
+
expect(described_class.new(rand_factor: 0.2).intervals).to eq(
|
|
70
|
+
[
|
|
71
|
+
0.5097627004884576,
|
|
72
|
+
0.8145568095504492,
|
|
73
|
+
1.1712435167599646,
|
|
74
|
+
],
|
|
75
|
+
)
|
|
68
76
|
end
|
|
69
77
|
|
|
70
78
|
it "generates 10 non-randomized intervals" do
|
|
71
79
|
non_random_intervals = 9.times.inject([0.5]) { |memo, _i| memo + [memo.last * 1.5] }
|
|
72
80
|
expect(described_class.new(tries: 10, rand_factor: 0.0).intervals).to eq(non_random_intervals)
|
|
73
81
|
end
|
|
82
|
+
|
|
83
|
+
it "provides capped intervals lazily" do
|
|
84
|
+
interval_for = described_class.new(
|
|
85
|
+
base_interval: 1.0,
|
|
86
|
+
multiplier: 2.0,
|
|
87
|
+
max_interval: 4.0,
|
|
88
|
+
rand_factor: 0.0,
|
|
89
|
+
).interval_provider
|
|
90
|
+
|
|
91
|
+
expect(Array.new(5) { |index| interval_for.call(index) }).to eq([1.0, 2.0, 4.0, 4.0, 4.0])
|
|
92
|
+
end
|
|
74
93
|
end
|