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/spec/retriable_spec.rb
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
3
5
|
describe Retriable do
|
|
4
6
|
let(:time_table_handler) do
|
|
5
7
|
->(_exception, try, _elapsed_time, next_interval) { @next_interval_table[try] = next_interval }
|
|
6
8
|
end
|
|
7
9
|
|
|
8
10
|
before(:each) do
|
|
11
|
+
described_class.instance_variable_set(:@config, nil)
|
|
12
|
+
Thread.current.thread_variable_set(Retriable::OVERRIDE_THREAD_KEY, nil)
|
|
9
13
|
described_class.configure { |c| c.sleep_disabled = true }
|
|
10
14
|
@tries = 0
|
|
11
15
|
@next_interval_table = {}
|
|
@@ -23,7 +27,9 @@ describe Retriable do
|
|
|
23
27
|
|
|
24
28
|
context "global scope extension" do
|
|
25
29
|
it "cannot be called in the global scope without requiring the core_ext/kernel" do
|
|
26
|
-
|
|
30
|
+
script = "require 'retriable'; begin; retriable {}; rescue NoMethodError; exit 0; end; exit 1"
|
|
31
|
+
|
|
32
|
+
expect(system(RbConfig.ruby, "-Ilib", "-e", script)).to be(true)
|
|
27
33
|
end
|
|
28
34
|
|
|
29
35
|
it "can be called once the kernel extension is required" do
|
|
@@ -32,12 +38,51 @@ describe Retriable do
|
|
|
32
38
|
expect { retriable { increment_tries_with_exception } }.to raise_error(StandardError)
|
|
33
39
|
expect(@tries).to eq(3)
|
|
34
40
|
end
|
|
41
|
+
|
|
42
|
+
it "passes on_give_up through the kernel extension" do
|
|
43
|
+
require_relative "../lib/retriable/core_ext/kernel"
|
|
44
|
+
received_reason = nil
|
|
45
|
+
handler = proc { |_e, _try, _elapsed, _interval, reason| received_reason = reason }
|
|
46
|
+
|
|
47
|
+
expect { retriable(tries: 1, on_give_up: handler) { increment_tries_with_exception } }
|
|
48
|
+
.to raise_error(StandardError)
|
|
49
|
+
|
|
50
|
+
expect(received_reason).to eq(:tries_exhausted)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# These two specs lock in the anonymous block forwarding (`&`) semantics
|
|
54
|
+
# across both delegation layers: Kernel#retriable_with_context ->
|
|
55
|
+
# Retriable.with_context. If the `&` is dropped at either layer, the
|
|
56
|
+
# block is not forwarded and the inner `block_given?` check at
|
|
57
|
+
# lib/retriable.rb:51 short-circuits, causing the block to never run.
|
|
58
|
+
it "forwards a block through Kernel#retriable_with_context" do
|
|
59
|
+
require_relative "../lib/retriable/core_ext/kernel"
|
|
60
|
+
Retriable.configure { |c| c.contexts[:sql] = { tries: 1 } }
|
|
61
|
+
|
|
62
|
+
retriable_with_context(:sql) { increment_tries }
|
|
63
|
+
|
|
64
|
+
expect(@tries).to eq(1)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it "returns nil when Kernel#retriable_with_context is called without a block" do
|
|
68
|
+
require_relative "../lib/retriable/core_ext/kernel"
|
|
69
|
+
Retriable.configure { |c| c.contexts[:sql] = { tries: 1 } }
|
|
70
|
+
|
|
71
|
+
expect(retriable_with_context(:sql)).to be_nil
|
|
72
|
+
expect(@tries).to eq(0)
|
|
73
|
+
end
|
|
35
74
|
end
|
|
36
75
|
|
|
37
76
|
context "#retriable" do
|
|
77
|
+
it "reuses the singleton config when no local options or overrides are provided" do
|
|
78
|
+
expect(described_class::Config).not_to receive(:new)
|
|
79
|
+
|
|
80
|
+
described_class.retriable { increment_tries }
|
|
81
|
+
expect(@tries).to eq(1)
|
|
82
|
+
end
|
|
83
|
+
|
|
38
84
|
it "raises a LocalJumpError if not given a block" do
|
|
39
85
|
expect { described_class.retriable }.to raise_error(LocalJumpError)
|
|
40
|
-
expect { described_class.retriable(timeout: 2) }.to raise_error(LocalJumpError)
|
|
41
86
|
end
|
|
42
87
|
|
|
43
88
|
it "stops at first try if the block does not raise an exception" do
|
|
@@ -71,8 +116,92 @@ describe Retriable do
|
|
|
71
116
|
expect(@tries).to eq(10)
|
|
72
117
|
end
|
|
73
118
|
|
|
74
|
-
it "
|
|
75
|
-
|
|
119
|
+
it "does not prebuild generated intervals before the first successful try" do
|
|
120
|
+
interval_for = ->(_index) { raise "interval should not be used" }
|
|
121
|
+
backoff = instance_double(Retriable::ExponentialBackoff, interval_provider: interval_for)
|
|
122
|
+
allow(Retriable::ExponentialBackoff).to receive(:new).and_call_original
|
|
123
|
+
allow(Retriable::ExponentialBackoff).to receive(:new).with(
|
|
124
|
+
hash_including(:base_interval, :multiplier, :max_interval, :rand_factor),
|
|
125
|
+
).and_return(backoff)
|
|
126
|
+
|
|
127
|
+
described_class.retriable(tries: 1_000_000) { increment_tries }
|
|
128
|
+
|
|
129
|
+
expect(@tries).to eq(1)
|
|
130
|
+
expect(backoff).to have_received(:interval_provider)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it "supports unbounded retries until the block succeeds" do
|
|
134
|
+
described_class.retriable(tries: Float::INFINITY, max_elapsed_time: 60) do
|
|
135
|
+
increment_tries
|
|
136
|
+
raise StandardError if @tries < 5
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
expect(@tries).to eq(5)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it "stops unbounded retries at max_elapsed_time" do
|
|
143
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
144
|
+
timeline = [
|
|
145
|
+
start_time,
|
|
146
|
+
start_time,
|
|
147
|
+
start_time,
|
|
148
|
+
start_time + 0.01,
|
|
149
|
+
start_time + 0.01,
|
|
150
|
+
start_time + 0.02,
|
|
151
|
+
start_time + 0.02,
|
|
152
|
+
]
|
|
153
|
+
allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC) { timeline.shift || timeline.last }
|
|
154
|
+
|
|
155
|
+
expect do
|
|
156
|
+
described_class.retriable(
|
|
157
|
+
tries: Float::INFINITY,
|
|
158
|
+
base_interval: 0.01,
|
|
159
|
+
multiplier: 1.0,
|
|
160
|
+
rand_factor: 0.0,
|
|
161
|
+
sleep_disabled: true,
|
|
162
|
+
max_elapsed_time: 0.015,
|
|
163
|
+
) do
|
|
164
|
+
increment_tries_with_exception
|
|
165
|
+
end
|
|
166
|
+
end.to raise_error(StandardError)
|
|
167
|
+
|
|
168
|
+
expect(@tries).to eq(3)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
it "raises ArgumentError when tries is Float::INFINITY without a finite max_elapsed_time" do
|
|
172
|
+
expect do
|
|
173
|
+
described_class.retriable(tries: Float::INFINITY, max_elapsed_time: nil) { increment_tries }
|
|
174
|
+
end.to raise_error(ArgumentError, /max_elapsed_time must be a finite number/)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
it "raises ArgumentError when tries is Float::INFINITY with infinite max_elapsed_time" do
|
|
178
|
+
expect do
|
|
179
|
+
described_class.retriable(tries: Float::INFINITY, max_elapsed_time: Float::INFINITY) { increment_tries }
|
|
180
|
+
end.to raise_error(ArgumentError, /max_elapsed_time must be a finite number/)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it "raises ArgumentError when tries is Float::INFINITY with custom intervals" do
|
|
184
|
+
expect do
|
|
185
|
+
described_class.retriable(tries: Float::INFINITY, intervals: [0.1, 0.2], max_elapsed_time: 60) do
|
|
186
|
+
increment_tries_with_exception
|
|
187
|
+
end
|
|
188
|
+
end.to raise_error(ArgumentError, /intervals cannot be used with tries: Float::INFINITY/)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it "raises ArgumentError when tries is Float::NAN" do
|
|
192
|
+
expect do
|
|
193
|
+
described_class.retriable(tries: Float::NAN) { increment_tries }
|
|
194
|
+
end.to raise_error(ArgumentError, /tries/)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
it "raises ArgumentError when tries is negative infinity" do
|
|
198
|
+
expect do
|
|
199
|
+
described_class.retriable(tries: -Float::INFINITY) { increment_tries }
|
|
200
|
+
end.to raise_error(ArgumentError, /tries/)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
it "rejects timeout as an unknown option" do
|
|
204
|
+
expect { described_class.retriable(timeout: 1) { :noop } }.to raise_error(ArgumentError, /not a valid option/)
|
|
76
205
|
end
|
|
77
206
|
|
|
78
207
|
it "applies a randomized exponential backoff to each try" do
|
|
@@ -118,6 +247,187 @@ describe Retriable do
|
|
|
118
247
|
end
|
|
119
248
|
end
|
|
120
249
|
|
|
250
|
+
it "does not call on_retry when explicitly set to nil" do
|
|
251
|
+
callback_called = false
|
|
252
|
+
original_on_retry = described_class.config.on_retry
|
|
253
|
+
|
|
254
|
+
begin
|
|
255
|
+
described_class.configure do |c|
|
|
256
|
+
c.on_retry = proc { |_exception, _try, _elapsed_time, _next_interval| callback_called = true }
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
expect do
|
|
260
|
+
described_class.retriable(on_retry: nil, tries: 3) { increment_tries_with_exception }
|
|
261
|
+
end.to raise_error(StandardError)
|
|
262
|
+
|
|
263
|
+
expect(@tries).to eq(3)
|
|
264
|
+
expect(callback_called).to be(false)
|
|
265
|
+
ensure
|
|
266
|
+
described_class.configure do |c|
|
|
267
|
+
c.on_retry = original_on_retry
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
it "calls on_give_up with max elapsed time details before re-raising" do
|
|
273
|
+
described_class.configure { |c| c.sleep_disabled = false }
|
|
274
|
+
give_up_calls = []
|
|
275
|
+
on_give_up = proc do |exception, try, elapsed_time, next_interval, reason|
|
|
276
|
+
give_up_calls << [exception, try, elapsed_time, next_interval, reason]
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
expect do
|
|
280
|
+
described_class.retriable(
|
|
281
|
+
intervals: [1.0, 1.0],
|
|
282
|
+
max_elapsed_time: 0.5,
|
|
283
|
+
on_give_up: on_give_up,
|
|
284
|
+
) do
|
|
285
|
+
increment_tries_with_exception
|
|
286
|
+
end
|
|
287
|
+
end.to raise_error(StandardError)
|
|
288
|
+
|
|
289
|
+
exception, try, elapsed_time, next_interval, reason = give_up_calls.fetch(0)
|
|
290
|
+
expect(give_up_calls.size).to eq(1)
|
|
291
|
+
expect(exception).to be_a(StandardError)
|
|
292
|
+
expect(exception.message).to eq("StandardError occurred")
|
|
293
|
+
expect(try).to eq(1)
|
|
294
|
+
expect(elapsed_time).to be >= 0
|
|
295
|
+
expect(next_interval).to eq(1.0)
|
|
296
|
+
expect(reason).to eq(:max_elapsed_time)
|
|
297
|
+
expect(@tries).to eq(1)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
it "calls on_give_up with tries exhausted details before re-raising" do
|
|
301
|
+
give_up_calls = []
|
|
302
|
+
on_give_up = proc do |exception, try, elapsed_time, next_interval, reason|
|
|
303
|
+
give_up_calls << [exception, try, elapsed_time, next_interval, reason]
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
expect do
|
|
307
|
+
described_class.retriable(tries: 2, on_give_up: on_give_up) { increment_tries_with_exception }
|
|
308
|
+
end.to raise_error(StandardError)
|
|
309
|
+
|
|
310
|
+
exception, try, elapsed_time, next_interval, reason = give_up_calls.fetch(0)
|
|
311
|
+
expect(give_up_calls.size).to eq(1)
|
|
312
|
+
expect(exception).to be_a(StandardError)
|
|
313
|
+
expect(exception.message).to eq("StandardError occurred")
|
|
314
|
+
expect(try).to eq(2)
|
|
315
|
+
expect(elapsed_time).to be >= 0
|
|
316
|
+
expect(next_interval).to be_nil
|
|
317
|
+
expect(reason).to eq(:tries_exhausted)
|
|
318
|
+
expect(@tries).to eq(2)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
it "does not call on_give_up when the block eventually succeeds" do
|
|
322
|
+
callback_called = false
|
|
323
|
+
|
|
324
|
+
described_class.retriable(tries: 3, on_give_up: proc { callback_called = true }) do
|
|
325
|
+
increment_tries
|
|
326
|
+
raise StandardError if @tries < 2
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
expect(callback_called).to be(false)
|
|
330
|
+
expect(@tries).to eq(2)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
it "does not call on_give_up for non-retriable exception types" do
|
|
334
|
+
callback_called = false
|
|
335
|
+
|
|
336
|
+
expect do
|
|
337
|
+
described_class.retriable(on_give_up: proc { callback_called = true }) do
|
|
338
|
+
increment_tries_with_exception(NonStandardError)
|
|
339
|
+
end
|
|
340
|
+
end.to raise_error(NonStandardError)
|
|
341
|
+
|
|
342
|
+
expect(callback_called).to be(false)
|
|
343
|
+
expect(@tries).to eq(1)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
it "does not call on_give_up when retry_if rejects the exception" do
|
|
347
|
+
callback_called = false
|
|
348
|
+
|
|
349
|
+
expect do
|
|
350
|
+
described_class.retriable(
|
|
351
|
+
tries: 3,
|
|
352
|
+
retry_if: ->(_exception) { false },
|
|
353
|
+
on_give_up: proc { callback_called = true },
|
|
354
|
+
) do
|
|
355
|
+
increment_tries_with_exception
|
|
356
|
+
end
|
|
357
|
+
end.to raise_error(StandardError)
|
|
358
|
+
|
|
359
|
+
expect(callback_called).to be(false)
|
|
360
|
+
expect(@tries).to eq(1)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
it "does not call on_give_up when explicitly set to false" do
|
|
364
|
+
callback_called = false
|
|
365
|
+
original_on_give_up = described_class.config.on_give_up
|
|
366
|
+
|
|
367
|
+
begin
|
|
368
|
+
described_class.configure do |c|
|
|
369
|
+
c.on_give_up = proc { callback_called = true }
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
expect do
|
|
373
|
+
described_class.retriable(on_give_up: false, tries: 1) { increment_tries_with_exception }
|
|
374
|
+
end.to raise_error(StandardError)
|
|
375
|
+
|
|
376
|
+
expect(callback_called).to be(false)
|
|
377
|
+
ensure
|
|
378
|
+
described_class.configure do |c|
|
|
379
|
+
c.on_give_up = original_on_give_up
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
it "does not call on_give_up when explicitly set to nil" do
|
|
385
|
+
callback_called = false
|
|
386
|
+
original_on_give_up = described_class.config.on_give_up
|
|
387
|
+
|
|
388
|
+
begin
|
|
389
|
+
described_class.configure do |c|
|
|
390
|
+
c.on_give_up = proc { callback_called = true }
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
expect do
|
|
394
|
+
described_class.retriable(on_give_up: nil, tries: 1) { increment_tries_with_exception }
|
|
395
|
+
end.to raise_error(StandardError)
|
|
396
|
+
|
|
397
|
+
expect(callback_called).to be(false)
|
|
398
|
+
ensure
|
|
399
|
+
described_class.configure do |c|
|
|
400
|
+
c.on_give_up = original_on_give_up
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
it "calls on_retry before on_give_up when giving up" do
|
|
406
|
+
events = []
|
|
407
|
+
|
|
408
|
+
expect do
|
|
409
|
+
described_class.retriable(
|
|
410
|
+
tries: 1,
|
|
411
|
+
on_retry: proc { events << :on_retry },
|
|
412
|
+
on_give_up: proc { events << :on_give_up },
|
|
413
|
+
) do
|
|
414
|
+
increment_tries_with_exception
|
|
415
|
+
end
|
|
416
|
+
end.to raise_error(StandardError)
|
|
417
|
+
|
|
418
|
+
expect(events).to eq(%i[on_retry on_give_up])
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
it "propagates exceptions raised inside on_give_up, replacing the original exception" do
|
|
422
|
+
handler = proc { raise "handler exploded" }
|
|
423
|
+
|
|
424
|
+
expect do
|
|
425
|
+
described_class.retriable(tries: 1, on_give_up: handler) { increment_tries_with_exception }
|
|
426
|
+
end.to raise_error(RuntimeError, "handler exploded")
|
|
427
|
+
|
|
428
|
+
expect(@tries).to eq(1)
|
|
429
|
+
end
|
|
430
|
+
|
|
121
431
|
context "with rand_factor 0.0 and an on_retry handler" do
|
|
122
432
|
let(:tries) { 6 }
|
|
123
433
|
let(:no_rand_timetable) { { 1 => 0.5, 2 => 0.75, 3 => 1.125 } }
|
|
@@ -197,6 +507,19 @@ describe Retriable do
|
|
|
197
507
|
end
|
|
198
508
|
end
|
|
199
509
|
|
|
510
|
+
context "with a Set :on parameter" do
|
|
511
|
+
it "retries each exception class in the Set" do
|
|
512
|
+
described_class.retriable(on: Set[StandardError, NonStandardError]) do
|
|
513
|
+
increment_tries
|
|
514
|
+
|
|
515
|
+
raise StandardError if @tries == 1
|
|
516
|
+
raise NonStandardError if @tries == 2
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
expect(@tries).to eq(3)
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
|
|
200
523
|
context "with a hash :on parameter" do
|
|
201
524
|
let(:on_hash) { { NonStandardError => /NonStandardError occurred/ } }
|
|
202
525
|
|
|
@@ -229,6 +552,20 @@ describe Retriable do
|
|
|
229
552
|
expect(@tries).to eq(1)
|
|
230
553
|
end
|
|
231
554
|
|
|
555
|
+
it "does not call on_give_up when exception class matches but message does not" do
|
|
556
|
+
callback_called = false
|
|
557
|
+
|
|
558
|
+
expect do
|
|
559
|
+
described_class.retriable(on: on_hash, on_give_up: proc { callback_called = true }) do
|
|
560
|
+
increment_tries
|
|
561
|
+
raise SecondNonStandardError, "not a match"
|
|
562
|
+
end
|
|
563
|
+
end.to raise_error(SecondNonStandardError, /not a match/)
|
|
564
|
+
|
|
565
|
+
expect(callback_called).to be(false)
|
|
566
|
+
expect(@tries).to eq(1)
|
|
567
|
+
end
|
|
568
|
+
|
|
232
569
|
it "successfully retries when the values are arrays of exception message patterns" do
|
|
233
570
|
exceptions = []
|
|
234
571
|
handler = ->(exception, try, _elapsed_time, _next_interval) { exceptions[try] = exception }
|
|
@@ -315,6 +652,18 @@ describe Retriable do
|
|
|
315
652
|
expect(@tries).to eq(2)
|
|
316
653
|
end
|
|
317
654
|
|
|
655
|
+
it "does not count skipped sleep intervals against max elapsed time" do
|
|
656
|
+
allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC).and_return(0.0)
|
|
657
|
+
|
|
658
|
+
expect do
|
|
659
|
+
described_class.retriable(tries: 3, base_interval: 1.0, rand_factor: 0.0, max_elapsed_time: 0.1) do
|
|
660
|
+
increment_tries_with_exception
|
|
661
|
+
end
|
|
662
|
+
end.to raise_error(StandardError)
|
|
663
|
+
|
|
664
|
+
expect(@tries).to eq(3)
|
|
665
|
+
end
|
|
666
|
+
|
|
318
667
|
it "retries up to tries limit when max_elapsed_time is nil" do
|
|
319
668
|
expect do
|
|
320
669
|
described_class.retriable(tries: 4, max_elapsed_time: nil) { increment_tries_with_exception }
|
|
@@ -350,6 +699,47 @@ describe Retriable do
|
|
|
350
699
|
it "raises ArgumentError on invalid options" do
|
|
351
700
|
expect { described_class.retriable(does_not_exist: 123) { increment_tries } }.to raise_error(ArgumentError)
|
|
352
701
|
end
|
|
702
|
+
|
|
703
|
+
it "raises ArgumentError when tries is not a positive integer" do
|
|
704
|
+
expect { described_class.retriable(tries: 1.5) { increment_tries } }
|
|
705
|
+
.to raise_error(ArgumentError, /tries/)
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
it "raises ArgumentError when an interval is negative" do
|
|
709
|
+
expect { described_class.retriable(intervals: [-1]) { increment_tries } }
|
|
710
|
+
.to raise_error(ArgumentError, /intervals/)
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
it "raises ArgumentError when configured timing options become invalid" do
|
|
714
|
+
described_class.configure { |config| config.tries = 0 }
|
|
715
|
+
|
|
716
|
+
expect { described_class.retriable { increment_tries } }
|
|
717
|
+
.to raise_error(ArgumentError, /tries/)
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
it "does not validate generated backoff options when intervals are provided" do
|
|
721
|
+
described_class.retriable(intervals: [0], tries: 0, rand_factor: 1.1) { increment_tries }
|
|
722
|
+
|
|
723
|
+
expect(@tries).to eq(1)
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
it "allows an empty interval array as one attempt" do
|
|
727
|
+
expect do
|
|
728
|
+
described_class.retriable(intervals: []) { increment_tries_with_exception }
|
|
729
|
+
end.to raise_error(StandardError)
|
|
730
|
+
|
|
731
|
+
expect(@tries).to eq(1)
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
it "rejects on: Object before invoking the block" do
|
|
735
|
+
block_invoked = false
|
|
736
|
+
|
|
737
|
+
expect do
|
|
738
|
+
described_class.retriable(on: Object) { block_invoked = true }
|
|
739
|
+
end.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
740
|
+
|
|
741
|
+
expect(block_invoked).to be(false)
|
|
742
|
+
end
|
|
353
743
|
end
|
|
354
744
|
|
|
355
745
|
context "#configure" do
|
|
@@ -359,6 +749,7 @@ describe Retriable do
|
|
|
359
749
|
with_context
|
|
360
750
|
configure
|
|
361
751
|
config
|
|
752
|
+
with_override
|
|
362
753
|
]
|
|
363
754
|
|
|
364
755
|
expect(described_class.singleton_methods(false)).to match_array(public_api_methods)
|
|
@@ -369,6 +760,479 @@ describe Retriable do
|
|
|
369
760
|
end
|
|
370
761
|
end
|
|
371
762
|
|
|
763
|
+
context "#retriable tries/intervals precedence" do
|
|
764
|
+
it "lets a per-call tries clear globally configured intervals" do
|
|
765
|
+
described_class.configure { |c| c.intervals = [0.5, 1.0] }
|
|
766
|
+
|
|
767
|
+
expect do
|
|
768
|
+
described_class.retriable(tries: 1) { increment_tries_with_exception }
|
|
769
|
+
end.to raise_error(StandardError)
|
|
770
|
+
|
|
771
|
+
expect(@tries).to eq(1)
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
it "still lets per-call intervals win when both intervals and tries are given" do
|
|
775
|
+
expect do
|
|
776
|
+
described_class.retriable(intervals: [0.5, 1.0], tries: 1) { increment_tries_with_exception }
|
|
777
|
+
end.to raise_error(StandardError)
|
|
778
|
+
|
|
779
|
+
expect(@tries).to eq(3) # intervals.size + 1
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
it "lets a with_context tries clear context intervals" do
|
|
783
|
+
described_class.configure do |c|
|
|
784
|
+
c.contexts[:api] = { intervals: [0.5, 1.0] }
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
expect do
|
|
788
|
+
described_class.with_context(:api, tries: 1) { increment_tries_with_exception }
|
|
789
|
+
end.to raise_error(StandardError)
|
|
790
|
+
|
|
791
|
+
expect(@tries).to eq(1)
|
|
792
|
+
end
|
|
793
|
+
end
|
|
794
|
+
|
|
795
|
+
context "#with_override" do
|
|
796
|
+
it "takes precedence over both global config and local options" do
|
|
797
|
+
described_class.configure { |c| c.tries = 2 }
|
|
798
|
+
|
|
799
|
+
described_class.with_override(tries: 1) do
|
|
800
|
+
expect { described_class.retriable(tries: 10) { increment_tries_with_exception } }.to raise_error(StandardError)
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
expect(@tries).to eq(1)
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
it "lets override tries take precedence over local intervals" do
|
|
807
|
+
described_class.with_override(tries: 1) do
|
|
808
|
+
expect do
|
|
809
|
+
described_class.retriable(intervals: [0.5, 1.0]) { increment_tries_with_exception }
|
|
810
|
+
end.to raise_error(StandardError)
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
expect(@tries).to eq(1)
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
it "lets override tries take precedence over context intervals" do
|
|
817
|
+
described_class.configure do |c|
|
|
818
|
+
c.contexts[:api] = { intervals: [0.5, 1.0] }
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
described_class.with_override(tries: 1) do
|
|
822
|
+
expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
expect(@tries).to eq(1)
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
it "lets override context tries take precedence over context intervals" do
|
|
829
|
+
described_class.configure do |c|
|
|
830
|
+
c.contexts[:api] = { intervals: [0.5, 1.0] }
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
described_class.with_override(contexts: { api: { tries: 1 } }) do
|
|
834
|
+
expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
expect(@tries).to eq(1)
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
it "replaces hash-valued options instead of deep-merging them" do
|
|
841
|
+
described_class.with_override(on: { NonStandardError => nil }) do
|
|
842
|
+
expect do
|
|
843
|
+
described_class.retriable(on: { StandardError => nil }, tries: 2) { increment_tries_with_exception }
|
|
844
|
+
end.to raise_error(StandardError)
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
expect(@tries).to eq(1)
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
it "can override local intervals with nil to use configured backoff" do
|
|
851
|
+
described_class.configure { |c| c.tries = 3 }
|
|
852
|
+
|
|
853
|
+
described_class.with_override(intervals: nil) do
|
|
854
|
+
expect do
|
|
855
|
+
described_class.retriable(intervals: [0.5, 1.0], on_retry: time_table_handler) do
|
|
856
|
+
increment_tries_with_exception
|
|
857
|
+
end
|
|
858
|
+
end.to raise_error(StandardError)
|
|
859
|
+
end
|
|
860
|
+
|
|
861
|
+
expect(@tries).to eq(3)
|
|
862
|
+
expect(@next_interval_table[1]).to be_between(0.0, 1.0)
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
it "applies override context values after with_context local options" do
|
|
866
|
+
described_class.configure do |c|
|
|
867
|
+
c.contexts[:api] = { tries: 3, base_interval: 1.0 }
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
described_class.with_override(contexts: { api: { tries: 1 } }) do
|
|
871
|
+
described_class.with_context(:api, tries: 10) { increment_tries }
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
expect(@tries).to eq(1)
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
it "can define a context only in override config" do
|
|
878
|
+
described_class.with_override(contexts: { test_only: { tries: 1 } }) do
|
|
879
|
+
described_class.with_context(:test_only) { increment_tries }
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
expect(@tries).to eq(1)
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
it "does not apply context-only overrides to plain retriable calls" do
|
|
886
|
+
described_class.with_override(contexts: { api: { tries: 1 } }) do
|
|
887
|
+
expect { described_class.retriable(tries: 3) { increment_tries_with_exception } }.to raise_error(StandardError)
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
expect(@tries).to eq(3)
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
it "keeps configured context matchers when top-level override values apply" do
|
|
894
|
+
described_class.configure do |c|
|
|
895
|
+
c.contexts[:api] = { tries: 3, on: NonStandardError }
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
described_class.with_override(tries: 1) do
|
|
899
|
+
expect { described_class.with_context(:api) { increment_tries_with_exception(NonStandardError) } }
|
|
900
|
+
.to raise_error(NonStandardError)
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
expect(@tries).to eq(1)
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
it "combines local options with override-only contexts" do
|
|
907
|
+
described_class.with_override(contexts: { api: { tries: 1 } }) do
|
|
908
|
+
expect do
|
|
909
|
+
described_class.with_context(:api, on: NonStandardError) do
|
|
910
|
+
increment_tries_with_exception(NonStandardError)
|
|
911
|
+
end
|
|
912
|
+
end.to raise_error(NonStandardError)
|
|
913
|
+
end
|
|
914
|
+
|
|
915
|
+
expect(@tries).to eq(1)
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
it "reuses configured contexts when override does not include contexts" do
|
|
919
|
+
described_class.configure do |c|
|
|
920
|
+
c.contexts[:api] = { tries: 1 }
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
described_class.with_override(tries: 1) do
|
|
924
|
+
described_class.with_context(:api) { increment_tries }
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
expect(@tries).to eq(1)
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
it "treats non-hash configured contexts as empty when override contexts are hash" do
|
|
931
|
+
described_class.configure { |c| c.contexts = nil }
|
|
932
|
+
|
|
933
|
+
described_class.with_override(contexts: { api: { tries: 1 } }) do
|
|
934
|
+
described_class.with_context(:api) { increment_tries }
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
expect(@tries).to eq(1)
|
|
938
|
+
ensure
|
|
939
|
+
described_class.configure { |c| c.contexts = {} }
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
it "ignores nil override contexts values in with_context" do
|
|
943
|
+
described_class.configure do |c|
|
|
944
|
+
c.contexts[:api] = { tries: 1 }
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
described_class.with_override(contexts: nil) do
|
|
948
|
+
described_class.with_context(:api) { increment_tries }
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
expect(@tries).to eq(1)
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
it "raises ArgumentError on non-hash override contexts values" do
|
|
955
|
+
block_called = false
|
|
956
|
+
|
|
957
|
+
expect { described_class.with_override(contexts: 123) { block_called = true } }
|
|
958
|
+
.to raise_error(ArgumentError, /contexts must be a Hash or nil/)
|
|
959
|
+
expect(block_called).to be(false)
|
|
960
|
+
end
|
|
961
|
+
|
|
962
|
+
it "raises ArgumentError on non-hash per-context override values" do
|
|
963
|
+
block_called = false
|
|
964
|
+
|
|
965
|
+
expect { described_class.with_override(contexts: { api: 123 }) { block_called = true } }
|
|
966
|
+
.to raise_error(ArgumentError, /contexts\[:api\] must be a Hash/)
|
|
967
|
+
expect(block_called).to be(false)
|
|
968
|
+
end
|
|
969
|
+
|
|
970
|
+
it "preserves outer override after rejected nested override contexts values" do
|
|
971
|
+
described_class.with_override(tries: 2) do
|
|
972
|
+
expect { described_class.with_override(tries: 1, contexts: 123) { :noop } }
|
|
973
|
+
.to raise_error(ArgumentError, /contexts must be a Hash or nil/)
|
|
974
|
+
|
|
975
|
+
expect { described_class.retriable(tries: 10) { increment_tries_with_exception } }
|
|
976
|
+
.to raise_error(StandardError)
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
expect(@tries).to eq(2)
|
|
980
|
+
end
|
|
981
|
+
|
|
982
|
+
it "preserves outer context override after rejected nested per-context values" do
|
|
983
|
+
described_class.configure do |c|
|
|
984
|
+
c.contexts[:api] = { tries: 10 }
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
described_class.with_override(contexts: { api: { tries: 2 } }) do
|
|
988
|
+
expect { described_class.with_override(contexts: { api: 123 }) { :noop } }
|
|
989
|
+
.to raise_error(ArgumentError, /contexts\[:api\] must be a Hash/)
|
|
990
|
+
|
|
991
|
+
expect { described_class.with_context(:api) { increment_tries_with_exception } }
|
|
992
|
+
.to raise_error(StandardError)
|
|
993
|
+
end
|
|
994
|
+
|
|
995
|
+
expect(@tries).to eq(2)
|
|
996
|
+
end
|
|
997
|
+
|
|
998
|
+
it "shows merged context keys in with_context missing-context errors" do
|
|
999
|
+
described_class.configure do |c|
|
|
1000
|
+
c.contexts[:configured] = { tries: 2 }
|
|
1001
|
+
end
|
|
1002
|
+
|
|
1003
|
+
described_class.with_override(contexts: { override_only: { tries: 1 } }) do
|
|
1004
|
+
expect { described_class.with_context(:missing) { increment_tries } }
|
|
1005
|
+
.to raise_error(ArgumentError, /override_only/)
|
|
1006
|
+
end
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
it "does not snapshot configured contexts when adding override-only contexts" do
|
|
1010
|
+
described_class.configure do |c|
|
|
1011
|
+
c.contexts[:api] = { tries: 2 }
|
|
1012
|
+
end
|
|
1013
|
+
|
|
1014
|
+
described_class.with_override(contexts: { test_only: { tries: 1 } }) do
|
|
1015
|
+
described_class.configure do |c|
|
|
1016
|
+
c.contexts[:api] = { tries: 5 }
|
|
1017
|
+
end
|
|
1018
|
+
|
|
1019
|
+
expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
|
|
1020
|
+
end
|
|
1021
|
+
|
|
1022
|
+
expect(@tries).to eq(5)
|
|
1023
|
+
end
|
|
1024
|
+
|
|
1025
|
+
it "raises ArgumentError on invalid override options" do
|
|
1026
|
+
expect { described_class.with_override(does_not_exist: 123) { :noop } }.to raise_error(ArgumentError)
|
|
1027
|
+
end
|
|
1028
|
+
|
|
1029
|
+
it "raises ArgumentError on empty override options" do
|
|
1030
|
+
expect { described_class.with_override({}) { :noop } }.to raise_error(ArgumentError, /empty override/)
|
|
1031
|
+
end
|
|
1032
|
+
|
|
1033
|
+
it "raises ArgumentError when called without a block" do
|
|
1034
|
+
expect { described_class.with_override(tries: 1) }.to raise_error(ArgumentError, /requires a block/)
|
|
1035
|
+
end
|
|
1036
|
+
|
|
1037
|
+
it "raises ArgumentError on invalid context override options" do
|
|
1038
|
+
expect { described_class.with_override(contexts: { api: { does_not_exist: 123 } }) { :noop } }
|
|
1039
|
+
.to raise_error(ArgumentError, /does_not_exist is not a valid option/)
|
|
1040
|
+
end
|
|
1041
|
+
|
|
1042
|
+
it "clears the override after the block returns" do
|
|
1043
|
+
described_class.with_override(tries: 1) do
|
|
1044
|
+
# active here
|
|
1045
|
+
end
|
|
1046
|
+
|
|
1047
|
+
expect { described_class.retriable(tries: 3) { increment_tries_with_exception } }.to raise_error(StandardError)
|
|
1048
|
+
expect(@tries).to eq(3)
|
|
1049
|
+
end
|
|
1050
|
+
|
|
1051
|
+
it "clears the override when the block raises" do
|
|
1052
|
+
expect do
|
|
1053
|
+
described_class.with_override(tries: 1) { raise "boom" }
|
|
1054
|
+
end.to raise_error(RuntimeError, "boom")
|
|
1055
|
+
|
|
1056
|
+
expect { described_class.retriable(tries: 3) { increment_tries_with_exception } }.to raise_error(StandardError)
|
|
1057
|
+
expect(@tries).to eq(3)
|
|
1058
|
+
end
|
|
1059
|
+
|
|
1060
|
+
it "returns the block's return value" do
|
|
1061
|
+
result = described_class.with_override(tries: 1) { :return_value }
|
|
1062
|
+
expect(result).to eq(:return_value)
|
|
1063
|
+
end
|
|
1064
|
+
|
|
1065
|
+
it "restores the outer override when nested blocks exit" do
|
|
1066
|
+
tries_seen = []
|
|
1067
|
+
handler = ->(_exception, try, _elapsed, _next) { tries_seen << [Thread.current.object_id, try] }
|
|
1068
|
+
|
|
1069
|
+
described_class.with_override(tries: 2, on_retry: handler) do
|
|
1070
|
+
described_class.with_override(tries: 4, on_retry: handler) do
|
|
1071
|
+
expect { described_class.retriable { increment_tries_with_exception } }.to raise_error(StandardError)
|
|
1072
|
+
end
|
|
1073
|
+
|
|
1074
|
+
# After the inner block exits, the outer tries: 2 override is restored.
|
|
1075
|
+
@tries = 0
|
|
1076
|
+
expect { described_class.retriable { increment_tries_with_exception } }.to raise_error(StandardError)
|
|
1077
|
+
expect(@tries).to eq(2)
|
|
1078
|
+
end
|
|
1079
|
+
end
|
|
1080
|
+
end
|
|
1081
|
+
|
|
1082
|
+
context "#with_override thread safety" do
|
|
1083
|
+
# Coordinate threads with queues rather than sleep so tests are deterministic.
|
|
1084
|
+
# sleep_disabled is already set to true in the top-level before(:each), so
|
|
1085
|
+
# retriable calls do not actually sleep between attempts.
|
|
1086
|
+
|
|
1087
|
+
it "isolates overrides between threads" do
|
|
1088
|
+
ready = Queue.new
|
|
1089
|
+
proceed = Queue.new
|
|
1090
|
+
results = {}
|
|
1091
|
+
mutex = Mutex.new
|
|
1092
|
+
|
|
1093
|
+
threads = [1, 2].map do |id|
|
|
1094
|
+
Thread.new do
|
|
1095
|
+
described_class.with_override(tries: id) do
|
|
1096
|
+
ready << true
|
|
1097
|
+
proceed.pop
|
|
1098
|
+
tries = 0
|
|
1099
|
+
begin
|
|
1100
|
+
described_class.retriable do
|
|
1101
|
+
tries += 1
|
|
1102
|
+
raise StandardError
|
|
1103
|
+
end
|
|
1104
|
+
rescue StandardError
|
|
1105
|
+
mutex.synchronize { results[id] = tries }
|
|
1106
|
+
end
|
|
1107
|
+
end
|
|
1108
|
+
end
|
|
1109
|
+
end
|
|
1110
|
+
|
|
1111
|
+
2.times { ready.pop }
|
|
1112
|
+
2.times { proceed << true }
|
|
1113
|
+
threads.each(&:join)
|
|
1114
|
+
|
|
1115
|
+
expect(results).to eq(1 => 1, 2 => 2)
|
|
1116
|
+
end
|
|
1117
|
+
|
|
1118
|
+
it "does not leak an active override into a sibling thread" do
|
|
1119
|
+
override_active = Queue.new
|
|
1120
|
+
sibling_done = Queue.new
|
|
1121
|
+
sibling_tries = nil
|
|
1122
|
+
|
|
1123
|
+
setter = Thread.new do
|
|
1124
|
+
described_class.with_override(tries: 1) do
|
|
1125
|
+
override_active << true
|
|
1126
|
+
sibling_done.pop
|
|
1127
|
+
end
|
|
1128
|
+
end
|
|
1129
|
+
|
|
1130
|
+
sibling = Thread.new do
|
|
1131
|
+
override_active.pop
|
|
1132
|
+
tries = 0
|
|
1133
|
+
begin
|
|
1134
|
+
described_class.retriable(tries: 3) do
|
|
1135
|
+
tries += 1
|
|
1136
|
+
raise StandardError
|
|
1137
|
+
end
|
|
1138
|
+
rescue StandardError
|
|
1139
|
+
sibling_tries = tries
|
|
1140
|
+
end
|
|
1141
|
+
sibling_done << true
|
|
1142
|
+
end
|
|
1143
|
+
|
|
1144
|
+
[setter, sibling].each(&:join)
|
|
1145
|
+
expect(sibling_tries).to eq(3)
|
|
1146
|
+
end
|
|
1147
|
+
|
|
1148
|
+
it "does not propagate an active override to a child thread" do
|
|
1149
|
+
child_tries = nil
|
|
1150
|
+
|
|
1151
|
+
described_class.with_override(tries: 1) do
|
|
1152
|
+
Thread.new do
|
|
1153
|
+
tries = 0
|
|
1154
|
+
begin
|
|
1155
|
+
described_class.retriable(tries: 3) do
|
|
1156
|
+
tries += 1
|
|
1157
|
+
raise StandardError
|
|
1158
|
+
end
|
|
1159
|
+
rescue StandardError
|
|
1160
|
+
child_tries = tries
|
|
1161
|
+
end
|
|
1162
|
+
end.join
|
|
1163
|
+
end
|
|
1164
|
+
|
|
1165
|
+
expect(child_tries).to eq(3)
|
|
1166
|
+
end
|
|
1167
|
+
|
|
1168
|
+
it "shares the active override with fibers in the same thread" do
|
|
1169
|
+
fiber_tries = nil
|
|
1170
|
+
|
|
1171
|
+
Thread.new do
|
|
1172
|
+
described_class.with_override(tries: 1) do
|
|
1173
|
+
Fiber.new do
|
|
1174
|
+
tries = 0
|
|
1175
|
+
begin
|
|
1176
|
+
described_class.retriable(tries: 10) do
|
|
1177
|
+
tries += 1
|
|
1178
|
+
raise StandardError
|
|
1179
|
+
end
|
|
1180
|
+
rescue StandardError
|
|
1181
|
+
fiber_tries = tries
|
|
1182
|
+
end
|
|
1183
|
+
end.resume
|
|
1184
|
+
end
|
|
1185
|
+
end.join
|
|
1186
|
+
|
|
1187
|
+
expect(fiber_tries).to eq(1)
|
|
1188
|
+
end
|
|
1189
|
+
|
|
1190
|
+
it "does not treat a main-thread override as a global default for other threads" do
|
|
1191
|
+
other_thread_tries = nil
|
|
1192
|
+
|
|
1193
|
+
described_class.with_override(tries: 1) do
|
|
1194
|
+
Thread.new do
|
|
1195
|
+
tries = 0
|
|
1196
|
+
begin
|
|
1197
|
+
described_class.retriable(tries: 3) do
|
|
1198
|
+
tries += 1
|
|
1199
|
+
raise StandardError
|
|
1200
|
+
end
|
|
1201
|
+
rescue StandardError
|
|
1202
|
+
other_thread_tries = tries
|
|
1203
|
+
end
|
|
1204
|
+
end.join
|
|
1205
|
+
end
|
|
1206
|
+
|
|
1207
|
+
expect(other_thread_tries).to eq(3)
|
|
1208
|
+
end
|
|
1209
|
+
|
|
1210
|
+
it "applies overridden on_give_up handlers" do
|
|
1211
|
+
callback_called = false
|
|
1212
|
+
|
|
1213
|
+
expect do
|
|
1214
|
+
described_class.with_override(on_give_up: proc { callback_called = true }) do
|
|
1215
|
+
described_class.retriable(tries: 1) { increment_tries_with_exception }
|
|
1216
|
+
end
|
|
1217
|
+
end.to raise_error(StandardError)
|
|
1218
|
+
|
|
1219
|
+
expect(callback_called).to be(true)
|
|
1220
|
+
end
|
|
1221
|
+
|
|
1222
|
+
it "applies on_give_up handlers configured via per-context overrides" do
|
|
1223
|
+
received_reason = nil
|
|
1224
|
+
handler = proc { |_e, _try, _elapsed, _interval, reason| received_reason = reason }
|
|
1225
|
+
|
|
1226
|
+
expect do
|
|
1227
|
+
described_class.with_override(contexts: { api: { tries: 1, on_give_up: handler } }) do
|
|
1228
|
+
described_class.with_context(:api) { increment_tries_with_exception }
|
|
1229
|
+
end
|
|
1230
|
+
end.to raise_error(StandardError)
|
|
1231
|
+
|
|
1232
|
+
expect(received_reason).to eq(:tries_exhausted)
|
|
1233
|
+
end
|
|
1234
|
+
end
|
|
1235
|
+
|
|
372
1236
|
context "#with_context" do
|
|
373
1237
|
let(:api_tries) { 4 }
|
|
374
1238
|
|
|
@@ -416,5 +1280,26 @@ describe Retriable do
|
|
|
416
1280
|
it "raises an ArgumentError when the context isn't found" do
|
|
417
1281
|
expect { described_class.with_context(:wtf) { increment_tries } }.to raise_error(ArgumentError, /wtf not found/)
|
|
418
1282
|
end
|
|
1283
|
+
|
|
1284
|
+
it "treats non-Hash context values as empty options" do
|
|
1285
|
+
described_class.configure do |c|
|
|
1286
|
+
c.contexts[:broken] = nil
|
|
1287
|
+
end
|
|
1288
|
+
|
|
1289
|
+
described_class.with_context(:broken) { increment_tries }
|
|
1290
|
+
expect(@tries).to eq(1)
|
|
1291
|
+
end
|
|
1292
|
+
|
|
1293
|
+
it "invokes on_give_up configured on a context" do
|
|
1294
|
+
callback_called = false
|
|
1295
|
+
described_class.configure do |c|
|
|
1296
|
+
c.contexts[:flaky] = { tries: 1, on_give_up: proc { callback_called = true } }
|
|
1297
|
+
end
|
|
1298
|
+
|
|
1299
|
+
expect { described_class.with_context(:flaky) { increment_tries_with_exception } }
|
|
1300
|
+
.to raise_error(StandardError)
|
|
1301
|
+
|
|
1302
|
+
expect(callback_called).to be(true)
|
|
1303
|
+
end
|
|
419
1304
|
end
|
|
420
1305
|
end
|