retriable 3.5.0 → 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.
@@ -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,39 @@ 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
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
41
74
  end
42
75
 
43
76
  context "#retriable" do
@@ -50,7 +83,6 @@ describe Retriable do
50
83
 
51
84
  it "raises a LocalJumpError if not given a block" do
52
85
  expect { described_class.retriable }.to raise_error(LocalJumpError)
53
- expect { described_class.retriable(timeout: 2) }.to raise_error(LocalJumpError)
54
86
  end
55
87
 
56
88
  it "stops at first try if the block does not raise an exception" do
@@ -84,8 +116,92 @@ describe Retriable do
84
116
  expect(@tries).to eq(10)
85
117
  end
86
118
 
87
- it "will timeout after 1 second" do
88
- expect { described_class.retriable(timeout: 1) { sleep(1.1) } }.to raise_error(Timeout::Error)
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/)
89
205
  end
90
206
 
91
207
  it "applies a randomized exponential backoff to each try" do
@@ -131,6 +247,187 @@ describe Retriable do
131
247
  end
132
248
  end
133
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
+
134
431
  context "with rand_factor 0.0 and an on_retry handler" do
135
432
  let(:tries) { 6 }
136
433
  let(:no_rand_timetable) { { 1 => 0.5, 2 => 0.75, 3 => 1.125 } }
@@ -210,6 +507,19 @@ describe Retriable do
210
507
  end
211
508
  end
212
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
+
213
523
  context "with a hash :on parameter" do
214
524
  let(:on_hash) { { NonStandardError => /NonStandardError occurred/ } }
215
525
 
@@ -242,6 +552,20 @@ describe Retriable do
242
552
  expect(@tries).to eq(1)
243
553
  end
244
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
+
245
569
  it "successfully retries when the values are arrays of exception message patterns" do
246
570
  exceptions = []
247
571
  handler = ->(exception, try, _elapsed_time, _next_interval) { exceptions[try] = exception }
@@ -375,6 +699,47 @@ describe Retriable do
375
699
  it "raises ArgumentError on invalid options" do
376
700
  expect { described_class.retriable(does_not_exist: 123) { increment_tries } }.to raise_error(ArgumentError)
377
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
378
743
  end
379
744
 
380
745
  context "#configure" do
@@ -384,8 +749,7 @@ describe Retriable do
384
749
  with_context
385
750
  configure
386
751
  config
387
- override
388
- reset_override
752
+ with_override
389
753
  ]
390
754
 
391
755
  expect(described_class.singleton_methods(false)).to match_array(public_api_methods)
@@ -396,25 +760,55 @@ describe Retriable do
396
760
  end
397
761
  end
398
762
 
399
- context "#override" do
400
- after(:each) do
401
- described_class.reset_override
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
402
780
  end
403
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
404
796
  it "takes precedence over both global config and local options" do
405
797
  described_class.configure { |c| c.tries = 2 }
406
- described_class.override(tries: 1)
407
798
 
408
- expect { described_class.retriable(tries: 10) { increment_tries_with_exception } }.to raise_error(StandardError)
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
+
409
803
  expect(@tries).to eq(1)
410
804
  end
411
805
 
412
806
  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)
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
418
812
 
419
813
  expect(@tries).to eq(1)
420
814
  end
@@ -423,9 +817,11 @@ describe Retriable do
423
817
  described_class.configure do |c|
424
818
  c.contexts[:api] = { intervals: [0.5, 1.0] }
425
819
  end
426
- described_class.override(tries: 1)
427
820
 
428
- expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
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
+
429
825
  expect(@tries).to eq(1)
430
826
  end
431
827
 
@@ -433,31 +829,34 @@ describe Retriable do
433
829
  described_class.configure do |c|
434
830
  c.contexts[:api] = { intervals: [0.5, 1.0] }
435
831
  end
436
- described_class.override(contexts: { api: { tries: 1 } })
437
832
 
438
- expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
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
+
439
837
  expect(@tries).to eq(1)
440
838
  end
441
839
 
442
840
  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)
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
448
846
 
449
847
  expect(@tries).to eq(1)
450
848
  end
451
849
 
452
850
  it "can override local intervals with nil to use configured backoff" do
453
851
  described_class.configure { |c| c.tries = 3 }
454
- described_class.override(intervals: nil)
455
852
 
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)
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
461
860
 
462
861
  expect(@tries).to eq(3)
463
862
  expect(@next_interval_table[1]).to be_between(0.0, 1.0)
@@ -468,23 +867,26 @@ describe Retriable do
468
867
  c.contexts[:api] = { tries: 3, base_interval: 1.0 }
469
868
  end
470
869
 
471
- described_class.override(contexts: { api: { tries: 1 } })
870
+ described_class.with_override(contexts: { api: { tries: 1 } }) do
871
+ described_class.with_context(:api, tries: 10) { increment_tries }
872
+ end
472
873
 
473
- described_class.with_context(:api, tries: 10) { increment_tries }
474
874
  expect(@tries).to eq(1)
475
875
  end
476
876
 
477
877
  it "can define a context only in override config" do
478
- described_class.override(contexts: { test_only: { tries: 1 } })
878
+ described_class.with_override(contexts: { test_only: { tries: 1 } }) do
879
+ described_class.with_context(:test_only) { increment_tries }
880
+ end
479
881
 
480
- described_class.with_context(:test_only) { increment_tries }
481
882
  expect(@tries).to eq(1)
482
883
  end
483
884
 
484
885
  it "does not apply context-only overrides to plain retriable calls" do
485
- described_class.override(contexts: { api: { tries: 1 } })
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
486
889
 
487
- expect { described_class.retriable(tries: 3) { increment_tries_with_exception } }.to raise_error(StandardError)
488
890
  expect(@tries).to eq(3)
489
891
  end
490
892
 
@@ -492,21 +894,24 @@ describe Retriable do
492
894
  described_class.configure do |c|
493
895
  c.contexts[:api] = { tries: 3, on: NonStandardError }
494
896
  end
495
- described_class.override(tries: 1)
496
897
 
497
- expect { described_class.with_context(:api) { increment_tries_with_exception(NonStandardError) } }
498
- .to raise_error(NonStandardError)
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
+
499
903
  expect(@tries).to eq(1)
500
904
  end
501
905
 
502
906
  it "combines local options with override-only contexts" do
503
- described_class.override(contexts: { api: { tries: 1 } })
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
504
914
 
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
915
  expect(@tries).to eq(1)
511
916
  end
512
917
 
@@ -515,23 +920,23 @@ describe Retriable do
515
920
  c.contexts[:api] = { tries: 1 }
516
921
  end
517
922
 
518
- described_class.override(tries: 1)
923
+ described_class.with_override(tries: 1) do
924
+ described_class.with_context(:api) { increment_tries }
925
+ end
519
926
 
520
- described_class.with_context(:api) { increment_tries }
521
927
  expect(@tries).to eq(1)
522
928
  end
523
929
 
524
930
  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 } })
931
+ described_class.configure { |c| c.contexts = nil }
529
932
 
933
+ described_class.with_override(contexts: { api: { tries: 1 } }) do
530
934
  described_class.with_context(:api) { increment_tries }
531
- expect(@tries).to eq(1)
532
- ensure
533
- described_class.configure { |c| c.contexts = {} }
534
935
  end
936
+
937
+ expect(@tries).to eq(1)
938
+ ensure
939
+ described_class.configure { |c| c.contexts = {} }
535
940
  end
536
941
 
537
942
  it "ignores nil override contexts values in with_context" do
@@ -539,31 +944,54 @@ describe Retriable do
539
944
  c.contexts[:api] = { tries: 1 }
540
945
  end
541
946
 
542
- described_class.override(contexts: nil)
947
+ described_class.with_override(contexts: nil) do
948
+ described_class.with_context(:api) { increment_tries }
949
+ end
543
950
 
544
- described_class.with_context(:api) { increment_tries }
545
951
  expect(@tries).to eq(1)
546
952
  end
547
953
 
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
954
+ it "raises ArgumentError on non-hash override contexts values" do
955
+ block_called = false
552
956
 
553
- described_class.override(contexts: 123)
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
554
961
 
555
- described_class.with_context(:api) { increment_tries }
556
- expect(@tries).to eq(1)
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)
557
968
  end
558
969
 
559
- it "ignores non-hash per-context override values in with_context" do
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
560
983
  described_class.configure do |c|
561
- c.contexts[:api] = { tries: 2 }
984
+ c.contexts[:api] = { tries: 10 }
562
985
  end
563
986
 
564
- described_class.override(contexts: { api: 123 })
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
565
994
 
566
- expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
567
995
  expect(@tries).to eq(2)
568
996
  end
569
997
 
@@ -572,10 +1000,10 @@ describe Retriable do
572
1000
  c.contexts[:configured] = { tries: 2 }
573
1001
  end
574
1002
 
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/)
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
579
1007
  end
580
1008
 
581
1009
  it "does not snapshot configured contexts when adding override-only contexts" do
@@ -583,37 +1011,225 @@ describe Retriable do
583
1011
  c.contexts[:api] = { tries: 2 }
584
1012
  end
585
1013
 
586
- described_class.override(contexts: { test_only: { tries: 1 } })
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
587
1018
 
588
- described_class.configure do |c|
589
- c.contexts[:api] = { tries: 5 }
1019
+ expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
590
1020
  end
591
1021
 
592
- expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
593
1022
  expect(@tries).to eq(5)
594
1023
  end
595
1024
 
596
1025
  it "raises ArgumentError on invalid override options" do
597
- expect { described_class.override(does_not_exist: 123) }.to raise_error(ArgumentError)
1026
+ expect { described_class.with_override(does_not_exist: 123) { :noop } }.to raise_error(ArgumentError)
598
1027
  end
599
1028
 
600
1029
  it "raises ArgumentError on empty override options" do
601
- expect { described_class.override({}) }.to raise_error(ArgumentError, /empty override/)
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/)
602
1035
  end
603
1036
 
604
1037
  it "raises ArgumentError on invalid context override options" do
605
- expect { described_class.override(contexts: { api: { does_not_exist: 123 } }) }
1038
+ expect { described_class.with_override(contexts: { api: { does_not_exist: 123 } }) { :noop } }
606
1039
  .to raise_error(ArgumentError, /does_not_exist is not a valid option/)
607
1040
  end
608
1041
 
609
- it "does not copy the provided override options" do
610
- opts = { tries: 1 }
611
- described_class.override(opts)
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
612
1050
 
613
- opts[:tries] = 2
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")
614
1055
 
615
- expect { described_class.retriable(tries: 10) { increment_tries_with_exception } }.to raise_error(StandardError)
616
- expect(@tries).to eq(2)
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)
617
1233
  end
618
1234
  end
619
1235
 
@@ -673,5 +1289,17 @@ describe Retriable do
673
1289
  described_class.with_context(:broken) { increment_tries }
674
1290
  expect(@tries).to eq(1)
675
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
676
1304
  end
677
1305
  end