retriable 3.4.1 → 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/.github/workflows/main.yml +5 -2
- data/CHANGELOG.md +29 -0
- data/README.md +148 -84
- 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 +141 -22
- data/spec/config_spec.rb +187 -0
- data/spec/exponential_backoff_spec.rb +45 -26
- data/spec/retriable_spec.rb +662 -3
- data/spec/spec_helper.rb +13 -0
- metadata +3 -1
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
|
|
@@ -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
|