retriable 3.8.0 → 5.0.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/AGENTS.md +15 -0
- data/CHANGELOG.md +159 -0
- data/Gemfile +6 -1
- data/README.md +131 -50
- data/benchmark/config_publication.rb +79 -0
- data/docs/adr/0001-copy-on-write-config-publication.md +124 -0
- data/docs/agents/domain.md +38 -0
- data/docs/agents/issue-tracker.md +45 -0
- data/docs/agents/triage-labels.md +17 -0
- data/docs/migration.md +84 -0
- data/lib/retriable/config.rb +123 -51
- data/lib/retriable/core_ext/kernel.rb +6 -4
- data/lib/retriable/exponential_backoff.rb +13 -5
- data/lib/retriable/validation.rb +11 -7
- data/lib/retriable/version.rb +1 -1
- data/lib/retriable.rb +155 -45
- data/retriable.gemspec +2 -7
- data/sig/retriable.rbs +29 -1
- data/spec/config_spec.rb +212 -97
- data/spec/retriable_spec.rb +666 -104
- data/spec/spec_helper.rb +3 -14
- metadata +14 -53
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
|
|
@@ -243,4 +163,199 @@ describe Retriable::Config do
|
|
|
243
163
|
.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
244
164
|
end
|
|
245
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
|
|
183
|
+
|
|
184
|
+
context "context structure validation" do
|
|
185
|
+
it "rejects a context whose options contain a nested :contexts key" do
|
|
186
|
+
expect { described_class.new(contexts: { api: { contexts: {} } }) }
|
|
187
|
+
.to raise_error(ArgumentError, /contexts is not a valid option/)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
it "rejects a context with an unknown option key" do
|
|
191
|
+
expect { described_class.new(contexts: { api: { does_not_exist: 1 } }) }
|
|
192
|
+
.to raise_error(ArgumentError, /does_not_exist is not a valid option/)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it "validates context structure even when intervals is provided" do
|
|
196
|
+
expect { described_class.new(intervals: [0.1], contexts: { api: { contexts: {} } }) }
|
|
197
|
+
.to raise_error(ArgumentError, /contexts is not a valid option/)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
it "accepts a non-Hash context value (treated as empty options)" do
|
|
201
|
+
expect { described_class.new(contexts: { broken: nil }) }.not_to raise_error
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
it "accepts nil contexts" do
|
|
205
|
+
expect { described_class.new(contexts: nil) }.not_to raise_error
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it "accepts a valid context" do
|
|
209
|
+
expect { described_class.new(contexts: { api: { tries: 3, base_interval: 1.0 } }) }.not_to raise_error
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
context "#dup (copy-on-write isolation)" do
|
|
214
|
+
it "deep-copies contexts so mutating the copy leaves the original intact" do
|
|
215
|
+
original = described_class.new(contexts: { sql: { tries: 1 } })
|
|
216
|
+
copy = original.dup
|
|
217
|
+
|
|
218
|
+
copy.contexts[:http] = { tries: 2 }
|
|
219
|
+
copy.contexts[:sql][:tries] = 99
|
|
220
|
+
|
|
221
|
+
expect(original.contexts).to eq(sql: { tries: 1 })
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
it "deep-copies on and intervals collections" do
|
|
225
|
+
original = described_class.new(on: [StandardError], intervals: [1, 2])
|
|
226
|
+
copy = original.dup
|
|
227
|
+
|
|
228
|
+
copy.on << ArgumentError
|
|
229
|
+
copy.intervals << 3
|
|
230
|
+
|
|
231
|
+
expect(original.on).to eq([StandardError])
|
|
232
|
+
expect(original.intervals).to eq([1, 2])
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
it "preserves a non-collection on value (Exception class) without duping it" do
|
|
236
|
+
original = described_class.new(on: StandardError)
|
|
237
|
+
expect(original.dup.on).to be(StandardError)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
it "deep-copies a Hash on value so the copy is a distinct hash" do
|
|
241
|
+
original = described_class.new(on: { StandardError => /boom/ })
|
|
242
|
+
copy = original.dup
|
|
243
|
+
|
|
244
|
+
copy.on[ArgumentError] = /other/
|
|
245
|
+
|
|
246
|
+
expect(original.on).to eq(StandardError => /boom/)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
it "deep-copies mutable values nested inside a context's options" do
|
|
250
|
+
original = described_class.new(contexts: { api: { intervals: [1, 2] } })
|
|
251
|
+
copy = original.dup
|
|
252
|
+
|
|
253
|
+
copy.contexts[:api][:intervals] << 3
|
|
254
|
+
|
|
255
|
+
expect(original.contexts[:api][:intervals]).to eq([1, 2])
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
it "deep-copies the collection values of a Hash on" do
|
|
259
|
+
original = described_class.new(on: { StandardError => [/boom/] })
|
|
260
|
+
copy = original.dup
|
|
261
|
+
|
|
262
|
+
copy.on[StandardError] << /bang/
|
|
263
|
+
|
|
264
|
+
expect(original.on[StandardError]).to eq([/boom/])
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
it "preserves the container class of a Hash subclass" do
|
|
268
|
+
subclass = Class.new(Hash)
|
|
269
|
+
contexts = subclass.new
|
|
270
|
+
contexts[:api] = { tries: 1 }
|
|
271
|
+
|
|
272
|
+
expect(described_class.new(contexts: contexts).dup.contexts).to be_a(subclass)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
it "preserves a contexts default_proc so absent keys still resolve" do
|
|
276
|
+
contexts = Hash.new { |hash, key| hash[key] = { tries: 7 } }
|
|
277
|
+
copy = described_class.new(contexts: contexts).dup
|
|
278
|
+
|
|
279
|
+
expect(copy.contexts[:never_set]).to eq(tries: 7)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
it "deep-copies a mutable Hash default" do
|
|
283
|
+
fallback = []
|
|
284
|
+
contexts = Hash.new(fallback)
|
|
285
|
+
copy = described_class.new(contexts: contexts).dup
|
|
286
|
+
|
|
287
|
+
copy.contexts.default << :copy_only
|
|
288
|
+
|
|
289
|
+
expect(copy.contexts.default).not_to equal(fallback)
|
|
290
|
+
expect(fallback).to be_empty
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
it "preserves a self-referential Hash default" do
|
|
294
|
+
contexts = {}
|
|
295
|
+
contexts.default = contexts
|
|
296
|
+
copy = described_class.new(contexts: contexts).dup
|
|
297
|
+
|
|
298
|
+
expect(copy.contexts.default).to equal(copy.contexts)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
it "unfreezes copied containers so a configure block can mutate them" do
|
|
302
|
+
original = described_class.new(contexts: { api: { tries: 1 } }.freeze)
|
|
303
|
+
|
|
304
|
+
expect { original.dup.contexts[:added] = { tries: 2 } }.not_to raise_error
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
it "terminates on a self-referential contexts structure" do
|
|
308
|
+
original = described_class.new
|
|
309
|
+
original.contexts[:api] = { tries: 1 }
|
|
310
|
+
original.contexts[:api][:cycle] = original.contexts
|
|
311
|
+
|
|
312
|
+
copy = original.dup
|
|
313
|
+
|
|
314
|
+
expect(copy.contexts).not_to equal(original.contexts)
|
|
315
|
+
expect(copy.contexts[:api][:cycle]).to equal(copy.contexts)
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
context "#freeze (published snapshot immutability)" do
|
|
320
|
+
it "rejects mutation of the config itself" do
|
|
321
|
+
config = described_class.new.freeze
|
|
322
|
+
|
|
323
|
+
expect { config.tries = 99 }.to raise_error(FrozenError)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
it "rejects mutation one level down, inside contexts" do
|
|
327
|
+
config = described_class.new(contexts: { api: { tries: 1 } }).freeze
|
|
328
|
+
|
|
329
|
+
expect { config.contexts[:api][:tries] = 99 }.to raise_error(FrozenError)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
it "rejects mutation of the on collection" do
|
|
333
|
+
config = described_class.new(on: [StandardError]).freeze
|
|
334
|
+
|
|
335
|
+
expect { config.on << ArgumentError }.to raise_error(FrozenError)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
it "freezes a mutable Hash default" do
|
|
339
|
+
fallback = []
|
|
340
|
+
contexts = Hash.new(fallback)
|
|
341
|
+
contexts[:api] = { tries: 1 }
|
|
342
|
+
config = described_class.new(contexts: contexts).freeze
|
|
343
|
+
|
|
344
|
+
expect(config.contexts.default).to be_frozen
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
it "leaves leaves such as procs untouched" do
|
|
348
|
+
handler = ->(_exception) { true }
|
|
349
|
+
described_class.new(retry_if: handler).freeze
|
|
350
|
+
|
|
351
|
+
expect(handler).not_to be_frozen
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
it "is idempotent" do
|
|
355
|
+
config = described_class.new.freeze
|
|
356
|
+
|
|
357
|
+
expect { config.freeze }.not_to raise_error
|
|
358
|
+
expect(config.freeze).to equal(config)
|
|
359
|
+
end
|
|
360
|
+
end
|
|
246
361
|
end
|