retriable 3.5.0 → 4.2.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.
@@ -9,7 +9,7 @@ describe Retriable do
9
9
 
10
10
  before(:each) do
11
11
  described_class.instance_variable_set(:@config, nil)
12
- described_class.reset_override
12
+ Thread.current.thread_variable_set(Retriable::OVERRIDE_THREAD_KEY, nil)
13
13
  described_class.configure { |c| c.sleep_disabled = true }
14
14
  @tries = 0
15
15
  @next_interval_table = {}
@@ -38,6 +38,49 @@ describe Retriable do
38
38
  expect { retriable { increment_tries_with_exception } }.to raise_error(StandardError)
39
39
  expect(@tries).to eq(3)
40
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 block
56
+ # is not forwarded and the `block_given?` guard in with_context raises
57
+ # ArgumentError instead of running the block.
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 "raises an ArgumentError 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) }
72
+ .to raise_error(ArgumentError, /with_context requires a block/)
73
+ expect(@tries).to eq(0)
74
+ end
75
+
76
+ it "is not callable with an explicit receiver" do
77
+ require_relative "../lib/retriable/core_ext/kernel"
78
+
79
+ expect { "foo".retriable { increment_tries } }
80
+ .to raise_error(NoMethodError, /private method/)
81
+ expect { "foo".retriable_with_context(:sql) { increment_tries } }
82
+ .to raise_error(NoMethodError, /private method/)
83
+ end
41
84
  end
42
85
 
43
86
  context "#retriable" do
@@ -50,7 +93,6 @@ describe Retriable do
50
93
 
51
94
  it "raises a LocalJumpError if not given a block" do
52
95
  expect { described_class.retriable }.to raise_error(LocalJumpError)
53
- expect { described_class.retriable(timeout: 2) }.to raise_error(LocalJumpError)
54
96
  end
55
97
 
56
98
  it "stops at first try if the block does not raise an exception" do
@@ -84,8 +126,92 @@ describe Retriable do
84
126
  expect(@tries).to eq(10)
85
127
  end
86
128
 
87
- it "will timeout after 1 second" do
88
- expect { described_class.retriable(timeout: 1) { sleep(1.1) } }.to raise_error(Timeout::Error)
129
+ it "does not prebuild generated intervals before the first successful try" do
130
+ interval_for = ->(_index) { raise "interval should not be used" }
131
+ backoff = instance_double(Retriable::ExponentialBackoff, interval_provider: interval_for)
132
+ allow(Retriable::ExponentialBackoff).to receive(:new).and_call_original
133
+ allow(Retriable::ExponentialBackoff).to receive(:new).with(
134
+ hash_including(:base_interval, :multiplier, :max_interval, :rand_factor),
135
+ ).and_return(backoff)
136
+
137
+ described_class.retriable(tries: 1_000_000) { increment_tries }
138
+
139
+ expect(@tries).to eq(1)
140
+ expect(backoff).to have_received(:interval_provider)
141
+ end
142
+
143
+ it "supports unbounded retries until the block succeeds" do
144
+ described_class.retriable(tries: Float::INFINITY, max_elapsed_time: 60) do
145
+ increment_tries
146
+ raise StandardError if @tries < 5
147
+ end
148
+
149
+ expect(@tries).to eq(5)
150
+ end
151
+
152
+ it "stops unbounded retries at max_elapsed_time" do
153
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
154
+ timeline = [
155
+ start_time,
156
+ start_time,
157
+ start_time,
158
+ start_time + 0.01,
159
+ start_time + 0.01,
160
+ start_time + 0.02,
161
+ start_time + 0.02,
162
+ ]
163
+ allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC) { timeline.shift || timeline.last }
164
+
165
+ expect do
166
+ described_class.retriable(
167
+ tries: Float::INFINITY,
168
+ base_interval: 0.01,
169
+ multiplier: 1.0,
170
+ rand_factor: 0.0,
171
+ sleep_disabled: true,
172
+ max_elapsed_time: 0.015,
173
+ ) do
174
+ increment_tries_with_exception
175
+ end
176
+ end.to raise_error(StandardError)
177
+
178
+ expect(@tries).to eq(3)
179
+ end
180
+
181
+ it "raises ArgumentError when tries is Float::INFINITY without a finite max_elapsed_time" do
182
+ expect do
183
+ described_class.retriable(tries: Float::INFINITY, max_elapsed_time: nil) { increment_tries }
184
+ end.to raise_error(ArgumentError, /max_elapsed_time must be a finite number/)
185
+ end
186
+
187
+ it "raises ArgumentError when tries is Float::INFINITY with infinite max_elapsed_time" do
188
+ expect do
189
+ described_class.retriable(tries: Float::INFINITY, max_elapsed_time: Float::INFINITY) { increment_tries }
190
+ end.to raise_error(ArgumentError, /max_elapsed_time must be a finite number/)
191
+ end
192
+
193
+ it "raises ArgumentError when tries is Float::INFINITY with custom intervals" do
194
+ expect do
195
+ described_class.retriable(tries: Float::INFINITY, intervals: [0.1, 0.2], max_elapsed_time: 60) do
196
+ increment_tries_with_exception
197
+ end
198
+ end.to raise_error(ArgumentError, /intervals cannot be used with tries: Float::INFINITY/)
199
+ end
200
+
201
+ it "raises ArgumentError when tries is Float::NAN" do
202
+ expect do
203
+ described_class.retriable(tries: Float::NAN) { increment_tries }
204
+ end.to raise_error(ArgumentError, /tries/)
205
+ end
206
+
207
+ it "raises ArgumentError when tries is negative infinity" do
208
+ expect do
209
+ described_class.retriable(tries: -Float::INFINITY) { increment_tries }
210
+ end.to raise_error(ArgumentError, /tries/)
211
+ end
212
+
213
+ it "rejects timeout as an unknown option" do
214
+ expect { described_class.retriable(timeout: 1) { :noop } }.to raise_error(ArgumentError, /not a valid option/)
89
215
  end
90
216
 
91
217
  it "applies a randomized exponential backoff to each try" do
@@ -131,6 +257,187 @@ describe Retriable do
131
257
  end
132
258
  end
133
259
 
260
+ it "does not call on_retry when explicitly set to nil" do
261
+ callback_called = false
262
+ original_on_retry = described_class.config.on_retry
263
+
264
+ begin
265
+ described_class.configure do |c|
266
+ c.on_retry = proc { |_exception, _try, _elapsed_time, _next_interval| callback_called = true }
267
+ end
268
+
269
+ expect do
270
+ described_class.retriable(on_retry: nil, tries: 3) { increment_tries_with_exception }
271
+ end.to raise_error(StandardError)
272
+
273
+ expect(@tries).to eq(3)
274
+ expect(callback_called).to be(false)
275
+ ensure
276
+ described_class.configure do |c|
277
+ c.on_retry = original_on_retry
278
+ end
279
+ end
280
+ end
281
+
282
+ it "calls on_give_up with max elapsed time details before re-raising" do
283
+ described_class.configure { |c| c.sleep_disabled = false }
284
+ give_up_calls = []
285
+ on_give_up = proc do |exception, try, elapsed_time, next_interval, reason|
286
+ give_up_calls << [exception, try, elapsed_time, next_interval, reason]
287
+ end
288
+
289
+ expect do
290
+ described_class.retriable(
291
+ intervals: [1.0, 1.0],
292
+ max_elapsed_time: 0.5,
293
+ on_give_up: on_give_up,
294
+ ) do
295
+ increment_tries_with_exception
296
+ end
297
+ end.to raise_error(StandardError)
298
+
299
+ exception, try, elapsed_time, next_interval, reason = give_up_calls.fetch(0)
300
+ expect(give_up_calls.size).to eq(1)
301
+ expect(exception).to be_a(StandardError)
302
+ expect(exception.message).to eq("StandardError occurred")
303
+ expect(try).to eq(1)
304
+ expect(elapsed_time).to be >= 0
305
+ expect(next_interval).to eq(1.0)
306
+ expect(reason).to eq(:max_elapsed_time)
307
+ expect(@tries).to eq(1)
308
+ end
309
+
310
+ it "calls on_give_up with tries exhausted details before re-raising" do
311
+ give_up_calls = []
312
+ on_give_up = proc do |exception, try, elapsed_time, next_interval, reason|
313
+ give_up_calls << [exception, try, elapsed_time, next_interval, reason]
314
+ end
315
+
316
+ expect do
317
+ described_class.retriable(tries: 2, on_give_up: on_give_up) { increment_tries_with_exception }
318
+ end.to raise_error(StandardError)
319
+
320
+ exception, try, elapsed_time, next_interval, reason = give_up_calls.fetch(0)
321
+ expect(give_up_calls.size).to eq(1)
322
+ expect(exception).to be_a(StandardError)
323
+ expect(exception.message).to eq("StandardError occurred")
324
+ expect(try).to eq(2)
325
+ expect(elapsed_time).to be >= 0
326
+ expect(next_interval).to be_nil
327
+ expect(reason).to eq(:tries_exhausted)
328
+ expect(@tries).to eq(2)
329
+ end
330
+
331
+ it "does not call on_give_up when the block eventually succeeds" do
332
+ callback_called = false
333
+
334
+ described_class.retriable(tries: 3, on_give_up: proc { callback_called = true }) do
335
+ increment_tries
336
+ raise StandardError if @tries < 2
337
+ end
338
+
339
+ expect(callback_called).to be(false)
340
+ expect(@tries).to eq(2)
341
+ end
342
+
343
+ it "does not call on_give_up for non-retriable exception types" do
344
+ callback_called = false
345
+
346
+ expect do
347
+ described_class.retriable(on_give_up: proc { callback_called = true }) do
348
+ increment_tries_with_exception(NonStandardError)
349
+ end
350
+ end.to raise_error(NonStandardError)
351
+
352
+ expect(callback_called).to be(false)
353
+ expect(@tries).to eq(1)
354
+ end
355
+
356
+ it "does not call on_give_up when retry_if rejects the exception" do
357
+ callback_called = false
358
+
359
+ expect do
360
+ described_class.retriable(
361
+ tries: 3,
362
+ retry_if: ->(_exception) { false },
363
+ on_give_up: proc { callback_called = true },
364
+ ) do
365
+ increment_tries_with_exception
366
+ end
367
+ end.to raise_error(StandardError)
368
+
369
+ expect(callback_called).to be(false)
370
+ expect(@tries).to eq(1)
371
+ end
372
+
373
+ it "does not call on_give_up when explicitly set to false" do
374
+ callback_called = false
375
+ original_on_give_up = described_class.config.on_give_up
376
+
377
+ begin
378
+ described_class.configure do |c|
379
+ c.on_give_up = proc { callback_called = true }
380
+ end
381
+
382
+ expect do
383
+ described_class.retriable(on_give_up: false, tries: 1) { increment_tries_with_exception }
384
+ end.to raise_error(StandardError)
385
+
386
+ expect(callback_called).to be(false)
387
+ ensure
388
+ described_class.configure do |c|
389
+ c.on_give_up = original_on_give_up
390
+ end
391
+ end
392
+ end
393
+
394
+ it "does not call on_give_up when explicitly set to nil" do
395
+ callback_called = false
396
+ original_on_give_up = described_class.config.on_give_up
397
+
398
+ begin
399
+ described_class.configure do |c|
400
+ c.on_give_up = proc { callback_called = true }
401
+ end
402
+
403
+ expect do
404
+ described_class.retriable(on_give_up: nil, tries: 1) { increment_tries_with_exception }
405
+ end.to raise_error(StandardError)
406
+
407
+ expect(callback_called).to be(false)
408
+ ensure
409
+ described_class.configure do |c|
410
+ c.on_give_up = original_on_give_up
411
+ end
412
+ end
413
+ end
414
+
415
+ it "calls on_retry before on_give_up when giving up" do
416
+ events = []
417
+
418
+ expect do
419
+ described_class.retriable(
420
+ tries: 1,
421
+ on_retry: proc { events << :on_retry },
422
+ on_give_up: proc { events << :on_give_up },
423
+ ) do
424
+ increment_tries_with_exception
425
+ end
426
+ end.to raise_error(StandardError)
427
+
428
+ expect(events).to eq(%i[on_retry on_give_up])
429
+ end
430
+
431
+ it "propagates exceptions raised inside on_give_up, replacing the original exception" do
432
+ handler = proc { raise "handler exploded" }
433
+
434
+ expect do
435
+ described_class.retriable(tries: 1, on_give_up: handler) { increment_tries_with_exception }
436
+ end.to raise_error(RuntimeError, "handler exploded")
437
+
438
+ expect(@tries).to eq(1)
439
+ end
440
+
134
441
  context "with rand_factor 0.0 and an on_retry handler" do
135
442
  let(:tries) { 6 }
136
443
  let(:no_rand_timetable) { { 1 => 0.5, 2 => 0.75, 3 => 1.125 } }
@@ -210,6 +517,19 @@ describe Retriable do
210
517
  end
211
518
  end
212
519
 
520
+ context "with a Set :on parameter" do
521
+ it "retries each exception class in the Set" do
522
+ described_class.retriable(on: Set[StandardError, NonStandardError]) do
523
+ increment_tries
524
+
525
+ raise StandardError if @tries == 1
526
+ raise NonStandardError if @tries == 2
527
+ end
528
+
529
+ expect(@tries).to eq(3)
530
+ end
531
+ end
532
+
213
533
  context "with a hash :on parameter" do
214
534
  let(:on_hash) { { NonStandardError => /NonStandardError occurred/ } }
215
535
 
@@ -242,6 +562,20 @@ describe Retriable do
242
562
  expect(@tries).to eq(1)
243
563
  end
244
564
 
565
+ it "does not call on_give_up when exception class matches but message does not" do
566
+ callback_called = false
567
+
568
+ expect do
569
+ described_class.retriable(on: on_hash, on_give_up: proc { callback_called = true }) do
570
+ increment_tries
571
+ raise SecondNonStandardError, "not a match"
572
+ end
573
+ end.to raise_error(SecondNonStandardError, /not a match/)
574
+
575
+ expect(callback_called).to be(false)
576
+ expect(@tries).to eq(1)
577
+ end
578
+
245
579
  it "successfully retries when the values are arrays of exception message patterns" do
246
580
  exceptions = []
247
581
  handler = ->(exception, try, _elapsed_time, _next_interval) { exceptions[try] = exception }
@@ -375,6 +709,47 @@ describe Retriable do
375
709
  it "raises ArgumentError on invalid options" do
376
710
  expect { described_class.retriable(does_not_exist: 123) { increment_tries } }.to raise_error(ArgumentError)
377
711
  end
712
+
713
+ it "raises ArgumentError when tries is not a positive integer" do
714
+ expect { described_class.retriable(tries: 1.5) { increment_tries } }
715
+ .to raise_error(ArgumentError, /tries/)
716
+ end
717
+
718
+ it "raises ArgumentError when an interval is negative" do
719
+ expect { described_class.retriable(intervals: [-1]) { increment_tries } }
720
+ .to raise_error(ArgumentError, /intervals/)
721
+ end
722
+
723
+ it "raises ArgumentError when configured timing options become invalid" do
724
+ described_class.configure { |config| config.tries = 0 }
725
+
726
+ expect { described_class.retriable { increment_tries } }
727
+ .to raise_error(ArgumentError, /tries/)
728
+ end
729
+
730
+ it "does not validate generated backoff options when intervals are provided" do
731
+ described_class.retriable(intervals: [0], tries: 0, rand_factor: 1.1) { increment_tries }
732
+
733
+ expect(@tries).to eq(1)
734
+ end
735
+
736
+ it "allows an empty interval array as one attempt" do
737
+ expect do
738
+ described_class.retriable(intervals: []) { increment_tries_with_exception }
739
+ end.to raise_error(StandardError)
740
+
741
+ expect(@tries).to eq(1)
742
+ end
743
+
744
+ it "rejects on: Object before invoking the block" do
745
+ block_invoked = false
746
+
747
+ expect do
748
+ described_class.retriable(on: Object) { block_invoked = true }
749
+ end.to raise_error(ArgumentError, /on must be an Exception class/)
750
+
751
+ expect(block_invoked).to be(false)
752
+ end
378
753
  end
379
754
 
380
755
  context "#configure" do
@@ -384,8 +759,7 @@ describe Retriable do
384
759
  with_context
385
760
  configure
386
761
  config
387
- override
388
- reset_override
762
+ with_override
389
763
  ]
390
764
 
391
765
  expect(described_class.singleton_methods(false)).to match_array(public_api_methods)
@@ -396,25 +770,55 @@ describe Retriable do
396
770
  end
397
771
  end
398
772
 
399
- context "#override" do
400
- after(:each) do
401
- described_class.reset_override
773
+ context "#retriable tries/intervals precedence" do
774
+ it "lets a per-call tries clear globally configured intervals" do
775
+ described_class.configure { |c| c.intervals = [0.5, 1.0] }
776
+
777
+ expect do
778
+ described_class.retriable(tries: 1) { increment_tries_with_exception }
779
+ end.to raise_error(StandardError)
780
+
781
+ expect(@tries).to eq(1)
402
782
  end
403
783
 
784
+ it "still lets per-call intervals win when both intervals and tries are given" do
785
+ expect do
786
+ described_class.retriable(intervals: [0.5, 1.0], tries: 1) { increment_tries_with_exception }
787
+ end.to raise_error(StandardError)
788
+
789
+ expect(@tries).to eq(3) # intervals.size + 1
790
+ end
791
+
792
+ it "lets a with_context tries clear context intervals" do
793
+ described_class.configure do |c|
794
+ c.contexts[:api] = { intervals: [0.5, 1.0] }
795
+ end
796
+
797
+ expect do
798
+ described_class.with_context(:api, tries: 1) { increment_tries_with_exception }
799
+ end.to raise_error(StandardError)
800
+
801
+ expect(@tries).to eq(1)
802
+ end
803
+ end
804
+
805
+ context "#with_override" do
404
806
  it "takes precedence over both global config and local options" do
405
807
  described_class.configure { |c| c.tries = 2 }
406
- described_class.override(tries: 1)
407
808
 
408
- expect { described_class.retriable(tries: 10) { increment_tries_with_exception } }.to raise_error(StandardError)
809
+ described_class.with_override(tries: 1) do
810
+ expect { described_class.retriable(tries: 10) { increment_tries_with_exception } }.to raise_error(StandardError)
811
+ end
812
+
409
813
  expect(@tries).to eq(1)
410
814
  end
411
815
 
412
816
  it "lets override tries take precedence over local intervals" do
413
- described_class.override(tries: 1)
414
-
415
- expect do
416
- described_class.retriable(intervals: [0.5, 1.0]) { increment_tries_with_exception }
417
- end.to raise_error(StandardError)
817
+ described_class.with_override(tries: 1) do
818
+ expect do
819
+ described_class.retriable(intervals: [0.5, 1.0]) { increment_tries_with_exception }
820
+ end.to raise_error(StandardError)
821
+ end
418
822
 
419
823
  expect(@tries).to eq(1)
420
824
  end
@@ -423,9 +827,11 @@ describe Retriable do
423
827
  described_class.configure do |c|
424
828
  c.contexts[:api] = { intervals: [0.5, 1.0] }
425
829
  end
426
- described_class.override(tries: 1)
427
830
 
428
- expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
831
+ described_class.with_override(tries: 1) do
832
+ expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
833
+ end
834
+
429
835
  expect(@tries).to eq(1)
430
836
  end
431
837
 
@@ -433,31 +839,34 @@ describe Retriable do
433
839
  described_class.configure do |c|
434
840
  c.contexts[:api] = { intervals: [0.5, 1.0] }
435
841
  end
436
- described_class.override(contexts: { api: { tries: 1 } })
437
842
 
438
- expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
843
+ described_class.with_override(contexts: { api: { tries: 1 } }) do
844
+ expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
845
+ end
846
+
439
847
  expect(@tries).to eq(1)
440
848
  end
441
849
 
442
850
  it "replaces hash-valued options instead of deep-merging them" do
443
- described_class.override(on: { NonStandardError => nil })
444
-
445
- expect do
446
- described_class.retriable(on: { StandardError => nil }, tries: 2) { increment_tries_with_exception }
447
- end.to raise_error(StandardError)
851
+ described_class.with_override(on: { NonStandardError => nil }) do
852
+ expect do
853
+ described_class.retriable(on: { StandardError => nil }, tries: 2) { increment_tries_with_exception }
854
+ end.to raise_error(StandardError)
855
+ end
448
856
 
449
857
  expect(@tries).to eq(1)
450
858
  end
451
859
 
452
860
  it "can override local intervals with nil to use configured backoff" do
453
861
  described_class.configure { |c| c.tries = 3 }
454
- described_class.override(intervals: nil)
455
862
 
456
- expect do
457
- described_class.retriable(intervals: [0.5, 1.0], on_retry: time_table_handler) do
458
- increment_tries_with_exception
459
- end
460
- end.to raise_error(StandardError)
863
+ described_class.with_override(intervals: nil) do
864
+ expect do
865
+ described_class.retriable(intervals: [0.5, 1.0], on_retry: time_table_handler) do
866
+ increment_tries_with_exception
867
+ end
868
+ end.to raise_error(StandardError)
869
+ end
461
870
 
462
871
  expect(@tries).to eq(3)
463
872
  expect(@next_interval_table[1]).to be_between(0.0, 1.0)
@@ -468,23 +877,26 @@ describe Retriable do
468
877
  c.contexts[:api] = { tries: 3, base_interval: 1.0 }
469
878
  end
470
879
 
471
- described_class.override(contexts: { api: { tries: 1 } })
880
+ described_class.with_override(contexts: { api: { tries: 1 } }) do
881
+ described_class.with_context(:api, tries: 10) { increment_tries }
882
+ end
472
883
 
473
- described_class.with_context(:api, tries: 10) { increment_tries }
474
884
  expect(@tries).to eq(1)
475
885
  end
476
886
 
477
887
  it "can define a context only in override config" do
478
- described_class.override(contexts: { test_only: { tries: 1 } })
888
+ described_class.with_override(contexts: { test_only: { tries: 1 } }) do
889
+ described_class.with_context(:test_only) { increment_tries }
890
+ end
479
891
 
480
- described_class.with_context(:test_only) { increment_tries }
481
892
  expect(@tries).to eq(1)
482
893
  end
483
894
 
484
895
  it "does not apply context-only overrides to plain retriable calls" do
485
- described_class.override(contexts: { api: { tries: 1 } })
896
+ described_class.with_override(contexts: { api: { tries: 1 } }) do
897
+ expect { described_class.retriable(tries: 3) { increment_tries_with_exception } }.to raise_error(StandardError)
898
+ end
486
899
 
487
- expect { described_class.retriable(tries: 3) { increment_tries_with_exception } }.to raise_error(StandardError)
488
900
  expect(@tries).to eq(3)
489
901
  end
490
902
 
@@ -492,21 +904,24 @@ describe Retriable do
492
904
  described_class.configure do |c|
493
905
  c.contexts[:api] = { tries: 3, on: NonStandardError }
494
906
  end
495
- described_class.override(tries: 1)
496
907
 
497
- expect { described_class.with_context(:api) { increment_tries_with_exception(NonStandardError) } }
498
- .to raise_error(NonStandardError)
908
+ described_class.with_override(tries: 1) do
909
+ expect { described_class.with_context(:api) { increment_tries_with_exception(NonStandardError) } }
910
+ .to raise_error(NonStandardError)
911
+ end
912
+
499
913
  expect(@tries).to eq(1)
500
914
  end
501
915
 
502
916
  it "combines local options with override-only contexts" do
503
- described_class.override(contexts: { api: { tries: 1 } })
917
+ described_class.with_override(contexts: { api: { tries: 1 } }) do
918
+ expect do
919
+ described_class.with_context(:api, on: NonStandardError) do
920
+ increment_tries_with_exception(NonStandardError)
921
+ end
922
+ end.to raise_error(NonStandardError)
923
+ end
504
924
 
505
- expect do
506
- described_class.with_context(:api, on: NonStandardError) do
507
- increment_tries_with_exception(NonStandardError)
508
- end
509
- end.to raise_error(NonStandardError)
510
925
  expect(@tries).to eq(1)
511
926
  end
512
927
 
@@ -515,23 +930,23 @@ describe Retriable do
515
930
  c.contexts[:api] = { tries: 1 }
516
931
  end
517
932
 
518
- described_class.override(tries: 1)
933
+ described_class.with_override(tries: 1) do
934
+ described_class.with_context(:api) { increment_tries }
935
+ end
519
936
 
520
- described_class.with_context(:api) { increment_tries }
521
937
  expect(@tries).to eq(1)
522
938
  end
523
939
 
524
940
  it "treats non-hash configured contexts as empty when override contexts are hash" do
525
- begin
526
- described_class.configure { |c| c.contexts = nil }
527
-
528
- described_class.override(contexts: { api: { tries: 1 } })
941
+ described_class.configure { |c| c.contexts = nil }
529
942
 
943
+ described_class.with_override(contexts: { api: { tries: 1 } }) do
530
944
  described_class.with_context(:api) { increment_tries }
531
- expect(@tries).to eq(1)
532
- ensure
533
- described_class.configure { |c| c.contexts = {} }
534
945
  end
946
+
947
+ expect(@tries).to eq(1)
948
+ ensure
949
+ described_class.configure { |c| c.contexts = {} }
535
950
  end
536
951
 
537
952
  it "ignores nil override contexts values in with_context" do
@@ -539,31 +954,54 @@ describe Retriable do
539
954
  c.contexts[:api] = { tries: 1 }
540
955
  end
541
956
 
542
- described_class.override(contexts: nil)
957
+ described_class.with_override(contexts: nil) do
958
+ described_class.with_context(:api) { increment_tries }
959
+ end
543
960
 
544
- described_class.with_context(:api) { increment_tries }
545
961
  expect(@tries).to eq(1)
546
962
  end
547
963
 
548
- it "ignores non-hash override contexts values in with_context" do
549
- described_class.configure do |c|
550
- c.contexts[:api] = { tries: 1 }
551
- end
964
+ it "raises ArgumentError on non-hash override contexts values" do
965
+ block_called = false
552
966
 
553
- described_class.override(contexts: 123)
967
+ expect { described_class.with_override(contexts: 123) { block_called = true } }
968
+ .to raise_error(ArgumentError, /contexts must be a Hash or nil/)
969
+ expect(block_called).to be(false)
970
+ end
554
971
 
555
- described_class.with_context(:api) { increment_tries }
556
- expect(@tries).to eq(1)
972
+ it "raises ArgumentError on non-hash per-context override values" do
973
+ block_called = false
974
+
975
+ expect { described_class.with_override(contexts: { api: 123 }) { block_called = true } }
976
+ .to raise_error(ArgumentError, /contexts\[:api\] must be a Hash/)
977
+ expect(block_called).to be(false)
978
+ end
979
+
980
+ it "preserves outer override after rejected nested override contexts values" do
981
+ described_class.with_override(tries: 2) do
982
+ expect { described_class.with_override(tries: 1, contexts: 123) { :noop } }
983
+ .to raise_error(ArgumentError, /contexts must be a Hash or nil/)
984
+
985
+ expect { described_class.retriable(tries: 10) { increment_tries_with_exception } }
986
+ .to raise_error(StandardError)
987
+ end
988
+
989
+ expect(@tries).to eq(2)
557
990
  end
558
991
 
559
- it "ignores non-hash per-context override values in with_context" do
992
+ it "preserves outer context override after rejected nested per-context values" do
560
993
  described_class.configure do |c|
561
- c.contexts[:api] = { tries: 2 }
994
+ c.contexts[:api] = { tries: 10 }
562
995
  end
563
996
 
564
- described_class.override(contexts: { api: 123 })
997
+ described_class.with_override(contexts: { api: { tries: 2 } }) do
998
+ expect { described_class.with_override(contexts: { api: 123 }) { :noop } }
999
+ .to raise_error(ArgumentError, /contexts\[:api\] must be a Hash/)
1000
+
1001
+ expect { described_class.with_context(:api) { increment_tries_with_exception } }
1002
+ .to raise_error(StandardError)
1003
+ end
565
1004
 
566
- expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
567
1005
  expect(@tries).to eq(2)
568
1006
  end
569
1007
 
@@ -572,10 +1010,10 @@ describe Retriable do
572
1010
  c.contexts[:configured] = { tries: 2 }
573
1011
  end
574
1012
 
575
- described_class.override(contexts: { override_only: { tries: 1 } })
576
-
577
- expect { described_class.with_context(:missing) { increment_tries } }
578
- .to raise_error(ArgumentError, /override_only/)
1013
+ described_class.with_override(contexts: { override_only: { tries: 1 } }) do
1014
+ expect { described_class.with_context(:missing) { increment_tries } }
1015
+ .to raise_error(ArgumentError, /override_only/)
1016
+ end
579
1017
  end
580
1018
 
581
1019
  it "does not snapshot configured contexts when adding override-only contexts" do
@@ -583,37 +1021,225 @@ describe Retriable do
583
1021
  c.contexts[:api] = { tries: 2 }
584
1022
  end
585
1023
 
586
- described_class.override(contexts: { test_only: { tries: 1 } })
1024
+ described_class.with_override(contexts: { test_only: { tries: 1 } }) do
1025
+ described_class.configure do |c|
1026
+ c.contexts[:api] = { tries: 5 }
1027
+ end
587
1028
 
588
- described_class.configure do |c|
589
- c.contexts[:api] = { tries: 5 }
1029
+ expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
590
1030
  end
591
1031
 
592
- expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
593
1032
  expect(@tries).to eq(5)
594
1033
  end
595
1034
 
596
1035
  it "raises ArgumentError on invalid override options" do
597
- expect { described_class.override(does_not_exist: 123) }.to raise_error(ArgumentError)
1036
+ expect { described_class.with_override(does_not_exist: 123) { :noop } }.to raise_error(ArgumentError)
598
1037
  end
599
1038
 
600
1039
  it "raises ArgumentError on empty override options" do
601
- expect { described_class.override({}) }.to raise_error(ArgumentError, /empty override/)
1040
+ expect { described_class.with_override({}) { :noop } }.to raise_error(ArgumentError, /empty override/)
1041
+ end
1042
+
1043
+ it "raises ArgumentError when called without a block" do
1044
+ expect { described_class.with_override(tries: 1) }.to raise_error(ArgumentError, /requires a block/)
602
1045
  end
603
1046
 
604
1047
  it "raises ArgumentError on invalid context override options" do
605
- expect { described_class.override(contexts: { api: { does_not_exist: 123 } }) }
1048
+ expect { described_class.with_override(contexts: { api: { does_not_exist: 123 } }) { :noop } }
606
1049
  .to raise_error(ArgumentError, /does_not_exist is not a valid option/)
607
1050
  end
608
1051
 
609
- it "does not copy the provided override options" do
610
- opts = { tries: 1 }
611
- described_class.override(opts)
1052
+ it "clears the override after the block returns" do
1053
+ described_class.with_override(tries: 1) do
1054
+ # active here
1055
+ end
612
1056
 
613
- opts[:tries] = 2
1057
+ expect { described_class.retriable(tries: 3) { increment_tries_with_exception } }.to raise_error(StandardError)
1058
+ expect(@tries).to eq(3)
1059
+ end
614
1060
 
615
- expect { described_class.retriable(tries: 10) { increment_tries_with_exception } }.to raise_error(StandardError)
616
- expect(@tries).to eq(2)
1061
+ it "clears the override when the block raises" do
1062
+ expect do
1063
+ described_class.with_override(tries: 1) { raise "boom" }
1064
+ end.to raise_error(RuntimeError, "boom")
1065
+
1066
+ expect { described_class.retriable(tries: 3) { increment_tries_with_exception } }.to raise_error(StandardError)
1067
+ expect(@tries).to eq(3)
1068
+ end
1069
+
1070
+ it "returns the block's return value" do
1071
+ result = described_class.with_override(tries: 1) { :return_value }
1072
+ expect(result).to eq(:return_value)
1073
+ end
1074
+
1075
+ it "restores the outer override when nested blocks exit" do
1076
+ tries_seen = []
1077
+ handler = ->(_exception, try, _elapsed, _next) { tries_seen << [Thread.current.object_id, try] }
1078
+
1079
+ described_class.with_override(tries: 2, on_retry: handler) do
1080
+ described_class.with_override(tries: 4, on_retry: handler) do
1081
+ expect { described_class.retriable { increment_tries_with_exception } }.to raise_error(StandardError)
1082
+ end
1083
+
1084
+ # After the inner block exits, the outer tries: 2 override is restored.
1085
+ @tries = 0
1086
+ expect { described_class.retriable { increment_tries_with_exception } }.to raise_error(StandardError)
1087
+ expect(@tries).to eq(2)
1088
+ end
1089
+ end
1090
+ end
1091
+
1092
+ context "#with_override thread safety" do
1093
+ # Coordinate threads with queues rather than sleep so tests are deterministic.
1094
+ # sleep_disabled is already set to true in the top-level before(:each), so
1095
+ # retriable calls do not actually sleep between attempts.
1096
+
1097
+ it "isolates overrides between threads" do
1098
+ ready = Queue.new
1099
+ proceed = Queue.new
1100
+ results = {}
1101
+ mutex = Mutex.new
1102
+
1103
+ threads = [1, 2].map do |id|
1104
+ Thread.new do
1105
+ described_class.with_override(tries: id) do
1106
+ ready << true
1107
+ proceed.pop
1108
+ tries = 0
1109
+ begin
1110
+ described_class.retriable do
1111
+ tries += 1
1112
+ raise StandardError
1113
+ end
1114
+ rescue StandardError
1115
+ mutex.synchronize { results[id] = tries }
1116
+ end
1117
+ end
1118
+ end
1119
+ end
1120
+
1121
+ 2.times { ready.pop }
1122
+ 2.times { proceed << true }
1123
+ threads.each(&:join)
1124
+
1125
+ expect(results).to eq(1 => 1, 2 => 2)
1126
+ end
1127
+
1128
+ it "does not leak an active override into a sibling thread" do
1129
+ override_active = Queue.new
1130
+ sibling_done = Queue.new
1131
+ sibling_tries = nil
1132
+
1133
+ setter = Thread.new do
1134
+ described_class.with_override(tries: 1) do
1135
+ override_active << true
1136
+ sibling_done.pop
1137
+ end
1138
+ end
1139
+
1140
+ sibling = Thread.new do
1141
+ override_active.pop
1142
+ tries = 0
1143
+ begin
1144
+ described_class.retriable(tries: 3) do
1145
+ tries += 1
1146
+ raise StandardError
1147
+ end
1148
+ rescue StandardError
1149
+ sibling_tries = tries
1150
+ end
1151
+ sibling_done << true
1152
+ end
1153
+
1154
+ [setter, sibling].each(&:join)
1155
+ expect(sibling_tries).to eq(3)
1156
+ end
1157
+
1158
+ it "does not propagate an active override to a child thread" do
1159
+ child_tries = nil
1160
+
1161
+ described_class.with_override(tries: 1) do
1162
+ Thread.new do
1163
+ tries = 0
1164
+ begin
1165
+ described_class.retriable(tries: 3) do
1166
+ tries += 1
1167
+ raise StandardError
1168
+ end
1169
+ rescue StandardError
1170
+ child_tries = tries
1171
+ end
1172
+ end.join
1173
+ end
1174
+
1175
+ expect(child_tries).to eq(3)
1176
+ end
1177
+
1178
+ it "shares the active override with fibers in the same thread" do
1179
+ fiber_tries = nil
1180
+
1181
+ Thread.new do
1182
+ described_class.with_override(tries: 1) do
1183
+ Fiber.new do
1184
+ tries = 0
1185
+ begin
1186
+ described_class.retriable(tries: 10) do
1187
+ tries += 1
1188
+ raise StandardError
1189
+ end
1190
+ rescue StandardError
1191
+ fiber_tries = tries
1192
+ end
1193
+ end.resume
1194
+ end
1195
+ end.join
1196
+
1197
+ expect(fiber_tries).to eq(1)
1198
+ end
1199
+
1200
+ it "does not treat a main-thread override as a global default for other threads" do
1201
+ other_thread_tries = nil
1202
+
1203
+ described_class.with_override(tries: 1) do
1204
+ Thread.new do
1205
+ tries = 0
1206
+ begin
1207
+ described_class.retriable(tries: 3) do
1208
+ tries += 1
1209
+ raise StandardError
1210
+ end
1211
+ rescue StandardError
1212
+ other_thread_tries = tries
1213
+ end
1214
+ end.join
1215
+ end
1216
+
1217
+ expect(other_thread_tries).to eq(3)
1218
+ end
1219
+
1220
+ it "applies overridden on_give_up handlers" do
1221
+ callback_called = false
1222
+
1223
+ expect do
1224
+ described_class.with_override(on_give_up: proc { callback_called = true }) do
1225
+ described_class.retriable(tries: 1) { increment_tries_with_exception }
1226
+ end
1227
+ end.to raise_error(StandardError)
1228
+
1229
+ expect(callback_called).to be(true)
1230
+ end
1231
+
1232
+ it "applies on_give_up handlers configured via per-context overrides" do
1233
+ received_reason = nil
1234
+ handler = proc { |_e, _try, _elapsed, _interval, reason| received_reason = reason }
1235
+
1236
+ expect do
1237
+ described_class.with_override(contexts: { api: { tries: 1, on_give_up: handler } }) do
1238
+ described_class.with_context(:api) { increment_tries_with_exception }
1239
+ end
1240
+ end.to raise_error(StandardError)
1241
+
1242
+ expect(received_reason).to eq(:tries_exhausted)
617
1243
  end
618
1244
  end
619
1245
 
@@ -632,8 +1258,15 @@ describe Retriable do
632
1258
  expect(@tries).to eq(1)
633
1259
  end
634
1260
 
635
- it "returns nil when called without a block" do
636
- expect(described_class.with_context(:sql)).to be_nil
1261
+ it "raises an ArgumentError when called without a block" do
1262
+ expect { described_class.with_context(:sql) }
1263
+ .to raise_error(ArgumentError, /with_context requires a block/)
1264
+ expect(@tries).to eq(0)
1265
+ end
1266
+
1267
+ it "checks for a block before looking up the context" do
1268
+ expect { described_class.with_context(:missing) }
1269
+ .to raise_error(ArgumentError, /with_context requires a block/)
637
1270
  expect(@tries).to eq(0)
638
1271
  end
639
1272
 
@@ -673,5 +1306,24 @@ describe Retriable do
673
1306
  described_class.with_context(:broken) { increment_tries }
674
1307
  expect(@tries).to eq(1)
675
1308
  end
1309
+
1310
+ it "surfaces an invalid context on any retriable call before that context is used" do
1311
+ described_class.configure { |c| c.contexts[:unused] = { contexts: {} } }
1312
+
1313
+ expect { described_class.retriable { :ok } }
1314
+ .to raise_error(ArgumentError, /contexts is not a valid option/)
1315
+ end
1316
+
1317
+ it "invokes on_give_up configured on a context" do
1318
+ callback_called = false
1319
+ described_class.configure do |c|
1320
+ c.contexts[:flaky] = { tries: 1, on_give_up: proc { callback_called = true } }
1321
+ end
1322
+
1323
+ expect { described_class.with_context(:flaky) { increment_tries_with_exception } }
1324
+ .to raise_error(StandardError)
1325
+
1326
+ expect(callback_called).to be(true)
1327
+ end
676
1328
  end
677
1329
  end