retriable 3.5.0 → 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 +38 -9
- data/.hound.yml +1 -1
- data/.rubocop.yml +4 -1
- data/CHANGELOG.md +72 -0
- data/Gemfile +4 -1
- data/README.md +187 -110
- 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 +116 -55
- 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 +713 -85
- 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,18 +25,22 @@ 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 = {}, &)
|
|
31
44
|
contexts = available_contexts
|
|
32
45
|
|
|
33
46
|
if !contexts.key?(context_key)
|
|
@@ -37,22 +50,25 @@ module Retriable
|
|
|
37
50
|
|
|
38
51
|
return unless block_given?
|
|
39
52
|
|
|
40
|
-
retriable(context_options_for(context_key, options), &
|
|
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,64 @@ 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
|
-
interval =
|
|
100
|
+
interval = interval_for.call(try - 1)
|
|
87
101
|
call_on_retry(on_retry, e, try, elapsed_time.call, interval)
|
|
88
102
|
|
|
89
103
|
elapsed_interval = sleep_disabled == true ? 0 : interval
|
|
90
|
-
|
|
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
|
|
91
111
|
|
|
92
112
|
sleep interval if sleep_disabled != true
|
|
93
113
|
end
|
|
94
114
|
end
|
|
95
115
|
end
|
|
96
116
|
|
|
97
|
-
def
|
|
98
|
-
return
|
|
117
|
+
def retry_plan(local_config)
|
|
118
|
+
return RetryPlan.new(nil, interval_provider(local_config)) if Validation.unbounded_tries?(local_config.tries)
|
|
99
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)
|
|
127
|
+
|
|
128
|
+
RetryPlan.new(max_tries, ->(index) { index < max_tries - 1 ? provider.call(index) : nil })
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def interval_provider(local_config)
|
|
100
132
|
ExponentialBackoff.new(
|
|
101
|
-
tries: tries - 1,
|
|
102
133
|
base_interval: local_config.base_interval,
|
|
103
134
|
multiplier: local_config.multiplier,
|
|
104
135
|
max_interval: local_config.max_interval,
|
|
105
136
|
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)
|
|
137
|
+
).interval_provider
|
|
113
138
|
end
|
|
114
139
|
|
|
115
140
|
def call_on_retry(on_retry, exception, try, elapsed_time, interval)
|
|
@@ -118,11 +143,24 @@ module Retriable
|
|
|
118
143
|
on_retry.call(exception, try, elapsed_time, interval)
|
|
119
144
|
end
|
|
120
145
|
|
|
121
|
-
def
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
124
150
|
|
|
125
|
-
(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
|
|
126
164
|
end
|
|
127
165
|
|
|
128
166
|
# When `on` is a Hash, we need to verify the exception matches a pattern.
|
|
@@ -151,16 +189,23 @@ module Retriable
|
|
|
151
189
|
raise ArgumentError, "#{k} is not a valid option" unless Config::ATTRIBUTES.include?(k)
|
|
152
190
|
end
|
|
153
191
|
|
|
192
|
+
return unless opts.key?(:contexts)
|
|
193
|
+
|
|
154
194
|
contexts = opts[:contexts]
|
|
155
|
-
return
|
|
195
|
+
return if contexts.nil?
|
|
196
|
+
|
|
197
|
+
raise ArgumentError, "contexts must be a Hash or nil, got #{contexts.inspect}" unless contexts.is_a?(Hash)
|
|
156
198
|
|
|
157
|
-
contexts.
|
|
158
|
-
validate_context_override_options(context_options)
|
|
199
|
+
contexts.each do |context_key, context_options|
|
|
200
|
+
validate_context_override_options(context_key, context_options)
|
|
159
201
|
end
|
|
160
202
|
end
|
|
161
203
|
|
|
162
|
-
def validate_context_override_options(context_options)
|
|
163
|
-
|
|
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
|
|
164
209
|
|
|
165
210
|
context_attributes = Config::ATTRIBUTES - [:contexts]
|
|
166
211
|
context_options.each_key do |k|
|
|
@@ -171,9 +216,17 @@ module Retriable
|
|
|
171
216
|
def apply_override_options(options, overrides)
|
|
172
217
|
return options unless overrides
|
|
173
218
|
|
|
174
|
-
options
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
177
230
|
end
|
|
178
231
|
|
|
179
232
|
def available_contexts
|
|
@@ -183,7 +236,7 @@ module Retriable
|
|
|
183
236
|
def context_options_for(context_key, options)
|
|
184
237
|
context_options = config_contexts.fetch(context_key, {})
|
|
185
238
|
context_options = {} unless context_options.is_a?(Hash)
|
|
186
|
-
context_options = context_options
|
|
239
|
+
context_options = merge_layer(context_options, options)
|
|
187
240
|
|
|
188
241
|
override_context_options = override_contexts[context_key]
|
|
189
242
|
return context_options unless override_context_options.is_a?(Hash)
|
|
@@ -196,24 +249,32 @@ module Retriable
|
|
|
196
249
|
end
|
|
197
250
|
|
|
198
251
|
def override_contexts
|
|
199
|
-
|
|
252
|
+
override_config = current_override
|
|
253
|
+
contexts = override_config && override_config[:contexts]
|
|
200
254
|
contexts.is_a?(Hash) ? contexts : {}
|
|
201
255
|
end
|
|
202
256
|
|
|
257
|
+
def current_override
|
|
258
|
+
Thread.current.thread_variable_get(OVERRIDE_THREAD_KEY)
|
|
259
|
+
end
|
|
260
|
+
|
|
203
261
|
private_class_method(
|
|
204
262
|
:validate_override_options,
|
|
205
263
|
:validate_context_override_options,
|
|
206
264
|
:execute_tries,
|
|
207
|
-
:
|
|
208
|
-
:
|
|
265
|
+
:retry_plan,
|
|
266
|
+
:interval_provider,
|
|
209
267
|
:call_on_retry,
|
|
210
|
-
:
|
|
268
|
+
:call_on_give_up,
|
|
269
|
+
:retry_stop_reason,
|
|
211
270
|
:retriable_exception?,
|
|
212
271
|
:hash_exception_match?,
|
|
213
272
|
:apply_override_options,
|
|
273
|
+
:merge_layer,
|
|
214
274
|
:available_contexts,
|
|
215
275
|
:context_options_for,
|
|
216
276
|
:config_contexts,
|
|
217
277
|
:override_contexts,
|
|
278
|
+
:current_override,
|
|
218
279
|
)
|
|
219
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
|