retriable 3.8.0 → 4.1.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 +20 -7
- data/.hound.yml +1 -1
- data/.rubocop.yml +4 -1
- data/CHANGELOG.md +28 -0
- data/Gemfile +2 -1
- data/README.md +93 -48
- data/lib/retriable/config.rb +3 -57
- data/lib/retriable/core_ext/kernel.rb +4 -4
- data/lib/retriable/validation.rb +4 -7
- data/lib/retriable/version.rb +1 -1
- data/lib/retriable.rb +50 -29
- data/retriable.gemspec +2 -7
- data/spec/config_spec.rb +17 -97
- data/spec/retriable_spec.rb +319 -93
- data/spec/spec_helper.rb +0 -13
- metadata +6 -52
data/lib/retriable.rb
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
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"
|
|
@@ -41,7 +40,7 @@ module Retriable
|
|
|
41
40
|
end
|
|
42
41
|
end
|
|
43
42
|
|
|
44
|
-
def with_context(context_key, options = {}, &
|
|
43
|
+
def with_context(context_key, options = {}, &)
|
|
45
44
|
contexts = available_contexts
|
|
46
45
|
|
|
47
46
|
if !contexts.key?(context_key)
|
|
@@ -51,25 +50,25 @@ module Retriable
|
|
|
51
50
|
|
|
52
51
|
return unless block_given?
|
|
53
52
|
|
|
54
|
-
retriable(context_options_for(context_key, options), &
|
|
53
|
+
retriable(context_options_for(context_key, options), &)
|
|
55
54
|
end
|
|
56
55
|
|
|
57
|
-
def retriable(opts = {}, &
|
|
56
|
+
def retriable(opts = {}, &)
|
|
58
57
|
override_config = current_override
|
|
59
58
|
local_config = if opts.empty? && !override_config
|
|
60
59
|
config
|
|
61
60
|
else
|
|
62
|
-
Config.new(apply_override_options(config.to_h
|
|
61
|
+
Config.new(apply_override_options(merge_layer(config.to_h, opts), override_config))
|
|
63
62
|
end
|
|
64
63
|
|
|
65
64
|
# Config is mutable through `configure`, so validate again immediately before use.
|
|
66
65
|
local_config.validate!
|
|
67
66
|
|
|
68
67
|
plan = retry_plan(local_config)
|
|
69
|
-
timeout = local_config.timeout
|
|
70
68
|
on = local_config.on
|
|
71
69
|
retry_if = local_config.retry_if
|
|
72
70
|
on_retry = local_config.on_retry
|
|
71
|
+
on_give_up = local_config.on_give_up
|
|
73
72
|
sleep_disabled = local_config.sleep_disabled
|
|
74
73
|
max_elapsed_time = local_config.max_elapsed_time
|
|
75
74
|
|
|
@@ -79,22 +78,22 @@ module Retriable
|
|
|
79
78
|
elapsed_time = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time }
|
|
80
79
|
|
|
81
80
|
execute_tries(
|
|
82
|
-
max_tries: plan.max_tries, interval_for: plan.interval_for,
|
|
81
|
+
max_tries: plan.max_tries, interval_for: plan.interval_for,
|
|
83
82
|
exception_list: exception_list, on: on, retry_if: retry_if, on_retry: on_retry,
|
|
84
|
-
elapsed_time: elapsed_time, max_elapsed_time: max_elapsed_time,
|
|
85
|
-
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, &
|
|
86
85
|
)
|
|
87
86
|
end
|
|
88
87
|
|
|
89
88
|
def execute_tries( # rubocop:disable Metrics/ParameterLists
|
|
90
|
-
max_tries:, interval_for:,
|
|
91
|
-
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:
|
|
92
91
|
)
|
|
93
92
|
try = 0
|
|
94
93
|
loop do
|
|
95
94
|
try += 1
|
|
96
95
|
begin
|
|
97
|
-
return
|
|
96
|
+
return yield(try)
|
|
98
97
|
rescue *exception_list => e
|
|
99
98
|
raise unless retriable_exception?(e, on, exception_list, retry_if)
|
|
100
99
|
|
|
@@ -102,7 +101,13 @@ module Retriable
|
|
|
102
101
|
call_on_retry(on_retry, e, try, elapsed_time.call, interval)
|
|
103
102
|
|
|
104
103
|
elapsed_interval = sleep_disabled == true ? 0 : interval
|
|
105
|
-
|
|
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
|
|
106
111
|
|
|
107
112
|
sleep interval if sleep_disabled != true
|
|
108
113
|
end
|
|
@@ -132,23 +137,30 @@ module Retriable
|
|
|
132
137
|
).interval_provider
|
|
133
138
|
end
|
|
134
139
|
|
|
135
|
-
def call_with_timeout(timeout, try)
|
|
136
|
-
return Timeout.timeout(timeout) { yield(try) } if timeout
|
|
137
|
-
|
|
138
|
-
yield(try)
|
|
139
|
-
end
|
|
140
|
-
|
|
141
140
|
def call_on_retry(on_retry, exception, try, elapsed_time, interval)
|
|
142
141
|
return unless on_retry
|
|
143
142
|
|
|
144
143
|
on_retry.call(exception, try, elapsed_time, interval)
|
|
145
144
|
end
|
|
146
145
|
|
|
147
|
-
def
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
150
150
|
|
|
151
|
-
(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
|
|
152
164
|
end
|
|
153
165
|
|
|
154
166
|
# When `on` is a Hash, we need to verify the exception matches a pattern.
|
|
@@ -204,9 +216,17 @@ module Retriable
|
|
|
204
216
|
def apply_override_options(options, overrides)
|
|
205
217
|
return options unless overrides
|
|
206
218
|
|
|
207
|
-
options
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
210
230
|
end
|
|
211
231
|
|
|
212
232
|
def available_contexts
|
|
@@ -216,7 +236,7 @@ module Retriable
|
|
|
216
236
|
def context_options_for(context_key, options)
|
|
217
237
|
context_options = config_contexts.fetch(context_key, {})
|
|
218
238
|
context_options = {} unless context_options.is_a?(Hash)
|
|
219
|
-
context_options = context_options
|
|
239
|
+
context_options = merge_layer(context_options, options)
|
|
220
240
|
|
|
221
241
|
override_context_options = override_contexts[context_key]
|
|
222
242
|
return context_options unless override_context_options.is_a?(Hash)
|
|
@@ -244,12 +264,13 @@ module Retriable
|
|
|
244
264
|
:execute_tries,
|
|
245
265
|
:retry_plan,
|
|
246
266
|
:interval_provider,
|
|
247
|
-
:call_with_timeout,
|
|
248
267
|
:call_on_retry,
|
|
249
|
-
:
|
|
268
|
+
:call_on_give_up,
|
|
269
|
+
:retry_stop_reason,
|
|
250
270
|
:retriable_exception?,
|
|
251
271
|
:hash_exception_match?,
|
|
252
272
|
:apply_override_options,
|
|
273
|
+
:merge_layer,
|
|
253
274
|
:available_contexts,
|
|
254
275
|
:context_options_for,
|
|
255
276
|
:config_contexts,
|
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/spec/config_spec.rb
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "stringio"
|
|
4
|
-
|
|
5
3
|
describe Retriable::Config do
|
|
6
4
|
let(:default_config) { described_class.new }
|
|
7
5
|
|
|
@@ -34,10 +32,6 @@ describe Retriable::Config do
|
|
|
34
32
|
expect(default_config.intervals).to be_nil
|
|
35
33
|
end
|
|
36
34
|
|
|
37
|
-
it "timeout defaults to nil" do
|
|
38
|
-
expect(default_config.timeout).to be_nil
|
|
39
|
-
end
|
|
40
|
-
|
|
41
35
|
it "on defaults to [StandardError]" do
|
|
42
36
|
expect(default_config.on).to eq([StandardError])
|
|
43
37
|
end
|
|
@@ -50,6 +44,10 @@ describe Retriable::Config do
|
|
|
50
44
|
expect(default_config.on_retry).to be_nil
|
|
51
45
|
end
|
|
52
46
|
|
|
47
|
+
it "on_give_up handler defaults to nil" do
|
|
48
|
+
expect(default_config.on_give_up).to be_nil
|
|
49
|
+
end
|
|
50
|
+
|
|
53
51
|
it "contexts defaults to {}" do
|
|
54
52
|
expect(default_config.contexts).to eq({})
|
|
55
53
|
end
|
|
@@ -59,99 +57,12 @@ describe Retriable::Config do
|
|
|
59
57
|
expect { described_class.new(does_not_exist: 123) }.to raise_error(ArgumentError, /not a valid option/)
|
|
60
58
|
end
|
|
61
59
|
|
|
62
|
-
it "
|
|
63
|
-
expect { described_class.new(
|
|
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
|
|
60
|
+
it "rejects timeout as an unknown option" do
|
|
61
|
+
expect { described_class.new(timeout: 5) }.to raise_error(ArgumentError, /not a valid option/)
|
|
67
62
|
end
|
|
68
63
|
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
64
|
+
it "raises errors on invalid timing configuration" do
|
|
65
|
+
expect { described_class.new(rand_factor: 1.1) }.to raise_error(ArgumentError, /rand_factor/)
|
|
155
66
|
end
|
|
156
67
|
|
|
157
68
|
it "raises errors when intervals is not an array" do
|
|
@@ -191,6 +102,15 @@ describe Retriable::Config do
|
|
|
191
102
|
expect { described_class.new(on: [StandardError, RuntimeError]) }.not_to raise_error
|
|
192
103
|
end
|
|
193
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
|
+
|
|
194
114
|
it "accepts a hash with nil pattern values" do
|
|
195
115
|
expect { described_class.new(on: { StandardError => nil }) }.not_to raise_error
|
|
196
116
|
end
|