retriable 3.5.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rbconfig"
4
+ require "stringio"
4
5
 
5
6
  describe Retriable do
6
7
  let(:time_table_handler) do
@@ -9,7 +10,7 @@ describe Retriable do
9
10
 
10
11
  before(:each) do
11
12
  described_class.instance_variable_set(:@config, nil)
12
- described_class.reset_override
13
+ Thread.current.thread_variable_set(Retriable::OVERRIDE_THREAD_KEY, nil)
13
14
  described_class.configure { |c| c.sleep_disabled = true }
14
15
  @tries = 0
15
16
  @next_interval_table = {}
@@ -50,7 +51,9 @@ describe Retriable do
50
51
 
51
52
  it "raises a LocalJumpError if not given a block" do
52
53
  expect { described_class.retriable }.to raise_error(LocalJumpError)
53
- expect { described_class.retriable(timeout: 2) }.to raise_error(LocalJumpError)
54
+ expect do
55
+ expect { described_class.retriable(timeout: 2) }.to raise_error(LocalJumpError)
56
+ end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
54
57
  end
55
58
 
56
59
  it "stops at first try if the block does not raise an exception" do
@@ -84,8 +87,170 @@ describe Retriable do
84
87
  expect(@tries).to eq(10)
85
88
  end
86
89
 
90
+ it "does not prebuild generated intervals before the first successful try" do
91
+ interval_for = ->(_index) { raise "interval should not be used" }
92
+ backoff = instance_double(Retriable::ExponentialBackoff, interval_provider: interval_for)
93
+ allow(Retriable::ExponentialBackoff).to receive(:new).and_call_original
94
+ allow(Retriable::ExponentialBackoff).to receive(:new).with(
95
+ hash_including(:base_interval, :multiplier, :max_interval, :rand_factor),
96
+ ).and_return(backoff)
97
+
98
+ described_class.retriable(tries: 1_000_000) { increment_tries }
99
+
100
+ expect(@tries).to eq(1)
101
+ expect(backoff).to have_received(:interval_provider)
102
+ end
103
+
104
+ it "supports unbounded retries until the block succeeds" do
105
+ described_class.retriable(tries: Float::INFINITY, max_elapsed_time: 60) do
106
+ increment_tries
107
+ raise StandardError if @tries < 5
108
+ end
109
+
110
+ expect(@tries).to eq(5)
111
+ end
112
+
113
+ it "stops unbounded retries at max_elapsed_time" do
114
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
115
+ timeline = [
116
+ start_time,
117
+ start_time,
118
+ start_time,
119
+ start_time + 0.01,
120
+ start_time + 0.01,
121
+ start_time + 0.02,
122
+ start_time + 0.02,
123
+ ]
124
+ allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC) { timeline.shift || timeline.last }
125
+
126
+ expect do
127
+ described_class.retriable(
128
+ tries: Float::INFINITY,
129
+ base_interval: 0.01,
130
+ multiplier: 1.0,
131
+ rand_factor: 0.0,
132
+ sleep_disabled: true,
133
+ max_elapsed_time: 0.015,
134
+ ) do
135
+ increment_tries_with_exception
136
+ end
137
+ end.to raise_error(StandardError)
138
+
139
+ expect(@tries).to eq(3)
140
+ end
141
+
142
+ it "raises ArgumentError when tries is Float::INFINITY without a finite max_elapsed_time" do
143
+ expect do
144
+ described_class.retriable(tries: Float::INFINITY, max_elapsed_time: nil) { increment_tries }
145
+ end.to raise_error(ArgumentError, /max_elapsed_time must be a finite number/)
146
+ end
147
+
148
+ it "raises ArgumentError when tries is Float::INFINITY with infinite max_elapsed_time" do
149
+ expect do
150
+ described_class.retriable(tries: Float::INFINITY, max_elapsed_time: Float::INFINITY) { increment_tries }
151
+ end.to raise_error(ArgumentError, /max_elapsed_time must be a finite number/)
152
+ end
153
+
154
+ it "raises ArgumentError when tries is Float::INFINITY with custom intervals" do
155
+ expect do
156
+ described_class.retriable(tries: Float::INFINITY, intervals: [0.1, 0.2], max_elapsed_time: 60) do
157
+ increment_tries_with_exception
158
+ end
159
+ end.to raise_error(ArgumentError, /intervals cannot be used with tries: Float::INFINITY/)
160
+ end
161
+
162
+ it "raises ArgumentError when tries is Float::NAN" do
163
+ expect do
164
+ described_class.retriable(tries: Float::NAN) { increment_tries }
165
+ end.to raise_error(ArgumentError, /tries/)
166
+ end
167
+
168
+ it "raises ArgumentError when tries is negative infinity" do
169
+ expect do
170
+ described_class.retriable(tries: -Float::INFINITY) { increment_tries }
171
+ end.to raise_error(ArgumentError, /tries/)
172
+ end
173
+
87
174
  it "will timeout after 1 second" do
88
- expect { described_class.retriable(timeout: 1) { sleep(1.1) } }.to raise_error(Timeout::Error)
175
+ expect do
176
+ expect { described_class.retriable(timeout: 1) { sleep(1.1) } }.to raise_error(Timeout::Error)
177
+ end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
178
+ end
179
+
180
+ context "timeout: deprecation" do
181
+ it "warns at most once per process across repeated retriable calls" do
182
+ original_stderr = $stderr
183
+ stderr = StringIO.new
184
+ begin
185
+ $stderr = stderr
186
+
187
+ described_class.retriable(timeout: 5) { :noop }
188
+ described_class.retriable(timeout: 5) { :noop }
189
+ described_class.retriable(timeout: 5) { :noop }
190
+
191
+ expect(stderr.string.scan("timeout:` option is deprecated").size).to eq(1)
192
+ ensure
193
+ $stderr = original_stderr
194
+ end
195
+ end
196
+
197
+ it "warns when timeout is passed to retriable" do
198
+ expect do
199
+ described_class.retriable(timeout: 5) { :noop }
200
+ end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
201
+ end
202
+
203
+ it "keeps applying timeout while deprecated" do
204
+ original_stderr = $stderr
205
+ begin
206
+ $stderr = StringIO.new
207
+ expect do
208
+ described_class.retriable(timeout: 0.05, tries: 1) { sleep(0.5) }
209
+ end.to raise_error(Timeout::Error)
210
+ ensure
211
+ $stderr = original_stderr
212
+ end
213
+ end
214
+
215
+ it "warns when timeout is supplied through with_override" do
216
+ expect do
217
+ described_class.with_override(timeout: 5) do
218
+ described_class.retriable { :noop }
219
+ end
220
+ end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
221
+ end
222
+
223
+ it "warns when timeout is supplied through configure" do
224
+ original_config = described_class.config
225
+ begin
226
+ expect do
227
+ described_class.configure { |config| config.timeout = 5 }
228
+ described_class.retriable { :noop }
229
+ end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
230
+ ensure
231
+ described_class.configure do |config|
232
+ original_config.to_h.each { |key, value| config.public_send("#{key}=", value) }
233
+ end
234
+ end
235
+ end
236
+
237
+ it "is silenced by Warning[:deprecated] = false", if: WARN_CATEGORY_SUPPORTED do
238
+ original = Warning[:deprecated]
239
+ begin
240
+ Warning[:deprecated] = false
241
+ expect do
242
+ described_class.retriable(timeout: 5) { :noop }
243
+ end.not_to output.to_stderr
244
+ ensure
245
+ Warning[:deprecated] = original
246
+ end
247
+ end
248
+
249
+ it "does not warn when timeout is absent" do
250
+ expect do
251
+ described_class.retriable { :noop }
252
+ end.not_to output.to_stderr
253
+ end
89
254
  end
90
255
 
91
256
  it "applies a randomized exponential backoff to each try" do
@@ -375,6 +540,47 @@ describe Retriable do
375
540
  it "raises ArgumentError on invalid options" do
376
541
  expect { described_class.retriable(does_not_exist: 123) { increment_tries } }.to raise_error(ArgumentError)
377
542
  end
543
+
544
+ it "raises ArgumentError when tries is not a positive integer" do
545
+ expect { described_class.retriable(tries: 1.5) { increment_tries } }
546
+ .to raise_error(ArgumentError, /tries/)
547
+ end
548
+
549
+ it "raises ArgumentError when an interval is negative" do
550
+ expect { described_class.retriable(intervals: [-1]) { increment_tries } }
551
+ .to raise_error(ArgumentError, /intervals/)
552
+ end
553
+
554
+ it "raises ArgumentError when configured timing options become invalid" do
555
+ described_class.configure { |config| config.tries = 0 }
556
+
557
+ expect { described_class.retriable { increment_tries } }
558
+ .to raise_error(ArgumentError, /tries/)
559
+ end
560
+
561
+ it "does not validate generated backoff options when intervals are provided" do
562
+ described_class.retriable(intervals: [0], tries: 0, rand_factor: 1.1) { increment_tries }
563
+
564
+ expect(@tries).to eq(1)
565
+ end
566
+
567
+ it "allows an empty interval array as one attempt" do
568
+ expect do
569
+ described_class.retriable(intervals: []) { increment_tries_with_exception }
570
+ end.to raise_error(StandardError)
571
+
572
+ expect(@tries).to eq(1)
573
+ end
574
+
575
+ it "rejects on: Object before invoking the block" do
576
+ block_invoked = false
577
+
578
+ expect do
579
+ described_class.retriable(on: Object) { block_invoked = true }
580
+ end.to raise_error(ArgumentError, /on must be an Exception class/)
581
+
582
+ expect(block_invoked).to be(false)
583
+ end
378
584
  end
379
585
 
380
586
  context "#configure" do
@@ -384,8 +590,7 @@ describe Retriable do
384
590
  with_context
385
591
  configure
386
592
  config
387
- override
388
- reset_override
593
+ with_override
389
594
  ]
390
595
 
391
596
  expect(described_class.singleton_methods(false)).to match_array(public_api_methods)
@@ -396,25 +601,23 @@ describe Retriable do
396
601
  end
397
602
  end
398
603
 
399
- context "#override" do
400
- after(:each) do
401
- described_class.reset_override
402
- end
403
-
604
+ context "#with_override" do
404
605
  it "takes precedence over both global config and local options" do
405
606
  described_class.configure { |c| c.tries = 2 }
406
- described_class.override(tries: 1)
407
607
 
408
- expect { described_class.retriable(tries: 10) { increment_tries_with_exception } }.to raise_error(StandardError)
608
+ described_class.with_override(tries: 1) do
609
+ expect { described_class.retriable(tries: 10) { increment_tries_with_exception } }.to raise_error(StandardError)
610
+ end
611
+
409
612
  expect(@tries).to eq(1)
410
613
  end
411
614
 
412
615
  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)
616
+ described_class.with_override(tries: 1) do
617
+ expect do
618
+ described_class.retriable(intervals: [0.5, 1.0]) { increment_tries_with_exception }
619
+ end.to raise_error(StandardError)
620
+ end
418
621
 
419
622
  expect(@tries).to eq(1)
420
623
  end
@@ -423,9 +626,11 @@ describe Retriable do
423
626
  described_class.configure do |c|
424
627
  c.contexts[:api] = { intervals: [0.5, 1.0] }
425
628
  end
426
- described_class.override(tries: 1)
427
629
 
428
- expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
630
+ described_class.with_override(tries: 1) do
631
+ expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
632
+ end
633
+
429
634
  expect(@tries).to eq(1)
430
635
  end
431
636
 
@@ -433,31 +638,34 @@ describe Retriable do
433
638
  described_class.configure do |c|
434
639
  c.contexts[:api] = { intervals: [0.5, 1.0] }
435
640
  end
436
- described_class.override(contexts: { api: { tries: 1 } })
437
641
 
438
- expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
642
+ described_class.with_override(contexts: { api: { tries: 1 } }) do
643
+ expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
644
+ end
645
+
439
646
  expect(@tries).to eq(1)
440
647
  end
441
648
 
442
649
  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)
650
+ described_class.with_override(on: { NonStandardError => nil }) do
651
+ expect do
652
+ described_class.retriable(on: { StandardError => nil }, tries: 2) { increment_tries_with_exception }
653
+ end.to raise_error(StandardError)
654
+ end
448
655
 
449
656
  expect(@tries).to eq(1)
450
657
  end
451
658
 
452
659
  it "can override local intervals with nil to use configured backoff" do
453
660
  described_class.configure { |c| c.tries = 3 }
454
- described_class.override(intervals: nil)
455
661
 
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)
662
+ described_class.with_override(intervals: nil) do
663
+ expect do
664
+ described_class.retriable(intervals: [0.5, 1.0], on_retry: time_table_handler) do
665
+ increment_tries_with_exception
666
+ end
667
+ end.to raise_error(StandardError)
668
+ end
461
669
 
462
670
  expect(@tries).to eq(3)
463
671
  expect(@next_interval_table[1]).to be_between(0.0, 1.0)
@@ -468,23 +676,26 @@ describe Retriable do
468
676
  c.contexts[:api] = { tries: 3, base_interval: 1.0 }
469
677
  end
470
678
 
471
- described_class.override(contexts: { api: { tries: 1 } })
679
+ described_class.with_override(contexts: { api: { tries: 1 } }) do
680
+ described_class.with_context(:api, tries: 10) { increment_tries }
681
+ end
472
682
 
473
- described_class.with_context(:api, tries: 10) { increment_tries }
474
683
  expect(@tries).to eq(1)
475
684
  end
476
685
 
477
686
  it "can define a context only in override config" do
478
- described_class.override(contexts: { test_only: { tries: 1 } })
687
+ described_class.with_override(contexts: { test_only: { tries: 1 } }) do
688
+ described_class.with_context(:test_only) { increment_tries }
689
+ end
479
690
 
480
- described_class.with_context(:test_only) { increment_tries }
481
691
  expect(@tries).to eq(1)
482
692
  end
483
693
 
484
694
  it "does not apply context-only overrides to plain retriable calls" do
485
- described_class.override(contexts: { api: { tries: 1 } })
695
+ described_class.with_override(contexts: { api: { tries: 1 } }) do
696
+ expect { described_class.retriable(tries: 3) { increment_tries_with_exception } }.to raise_error(StandardError)
697
+ end
486
698
 
487
- expect { described_class.retriable(tries: 3) { increment_tries_with_exception } }.to raise_error(StandardError)
488
699
  expect(@tries).to eq(3)
489
700
  end
490
701
 
@@ -492,21 +703,24 @@ describe Retriable do
492
703
  described_class.configure do |c|
493
704
  c.contexts[:api] = { tries: 3, on: NonStandardError }
494
705
  end
495
- described_class.override(tries: 1)
496
706
 
497
- expect { described_class.with_context(:api) { increment_tries_with_exception(NonStandardError) } }
498
- .to raise_error(NonStandardError)
707
+ described_class.with_override(tries: 1) do
708
+ expect { described_class.with_context(:api) { increment_tries_with_exception(NonStandardError) } }
709
+ .to raise_error(NonStandardError)
710
+ end
711
+
499
712
  expect(@tries).to eq(1)
500
713
  end
501
714
 
502
715
  it "combines local options with override-only contexts" do
503
- described_class.override(contexts: { api: { tries: 1 } })
716
+ described_class.with_override(contexts: { api: { tries: 1 } }) do
717
+ expect do
718
+ described_class.with_context(:api, on: NonStandardError) do
719
+ increment_tries_with_exception(NonStandardError)
720
+ end
721
+ end.to raise_error(NonStandardError)
722
+ end
504
723
 
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
724
  expect(@tries).to eq(1)
511
725
  end
512
726
 
@@ -515,9 +729,10 @@ describe Retriable do
515
729
  c.contexts[:api] = { tries: 1 }
516
730
  end
517
731
 
518
- described_class.override(tries: 1)
732
+ described_class.with_override(tries: 1) do
733
+ described_class.with_context(:api) { increment_tries }
734
+ end
519
735
 
520
- described_class.with_context(:api) { increment_tries }
521
736
  expect(@tries).to eq(1)
522
737
  end
523
738
 
@@ -525,9 +740,10 @@ describe Retriable do
525
740
  begin
526
741
  described_class.configure { |c| c.contexts = nil }
527
742
 
528
- described_class.override(contexts: { api: { tries: 1 } })
743
+ described_class.with_override(contexts: { api: { tries: 1 } }) do
744
+ described_class.with_context(:api) { increment_tries }
745
+ end
529
746
 
530
- described_class.with_context(:api) { increment_tries }
531
747
  expect(@tries).to eq(1)
532
748
  ensure
533
749
  described_class.configure { |c| c.contexts = {} }
@@ -539,31 +755,54 @@ describe Retriable do
539
755
  c.contexts[:api] = { tries: 1 }
540
756
  end
541
757
 
542
- described_class.override(contexts: nil)
758
+ described_class.with_override(contexts: nil) do
759
+ described_class.with_context(:api) { increment_tries }
760
+ end
543
761
 
544
- described_class.with_context(:api) { increment_tries }
545
762
  expect(@tries).to eq(1)
546
763
  end
547
764
 
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
765
+ it "raises ArgumentError on non-hash override contexts values" do
766
+ block_called = false
552
767
 
553
- described_class.override(contexts: 123)
768
+ expect { described_class.with_override(contexts: 123) { block_called = true } }
769
+ .to raise_error(ArgumentError, /contexts must be a Hash or nil/)
770
+ expect(block_called).to be(false)
771
+ end
554
772
 
555
- described_class.with_context(:api) { increment_tries }
556
- expect(@tries).to eq(1)
773
+ it "raises ArgumentError on non-hash per-context override values" do
774
+ block_called = false
775
+
776
+ expect { described_class.with_override(contexts: { api: 123 }) { block_called = true } }
777
+ .to raise_error(ArgumentError, /contexts\[:api\] must be a Hash/)
778
+ expect(block_called).to be(false)
779
+ end
780
+
781
+ it "preserves outer override after rejected nested override contexts values" do
782
+ described_class.with_override(tries: 2) do
783
+ expect { described_class.with_override(tries: 1, contexts: 123) { :noop } }
784
+ .to raise_error(ArgumentError, /contexts must be a Hash or nil/)
785
+
786
+ expect { described_class.retriable(tries: 10) { increment_tries_with_exception } }
787
+ .to raise_error(StandardError)
788
+ end
789
+
790
+ expect(@tries).to eq(2)
557
791
  end
558
792
 
559
- it "ignores non-hash per-context override values in with_context" do
793
+ it "preserves outer context override after rejected nested per-context values" do
560
794
  described_class.configure do |c|
561
- c.contexts[:api] = { tries: 2 }
795
+ c.contexts[:api] = { tries: 10 }
562
796
  end
563
797
 
564
- described_class.override(contexts: { api: 123 })
798
+ described_class.with_override(contexts: { api: { tries: 2 } }) do
799
+ expect { described_class.with_override(contexts: { api: 123 }) { :noop } }
800
+ .to raise_error(ArgumentError, /contexts\[:api\] must be a Hash/)
801
+
802
+ expect { described_class.with_context(:api) { increment_tries_with_exception } }
803
+ .to raise_error(StandardError)
804
+ end
565
805
 
566
- expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
567
806
  expect(@tries).to eq(2)
568
807
  end
569
808
 
@@ -572,10 +811,10 @@ describe Retriable do
572
811
  c.contexts[:configured] = { tries: 2 }
573
812
  end
574
813
 
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/)
814
+ described_class.with_override(contexts: { override_only: { tries: 1 } }) do
815
+ expect { described_class.with_context(:missing) { increment_tries } }
816
+ .to raise_error(ArgumentError, /override_only/)
817
+ end
579
818
  end
580
819
 
581
820
  it "does not snapshot configured contexts when adding override-only contexts" do
@@ -583,37 +822,200 @@ describe Retriable do
583
822
  c.contexts[:api] = { tries: 2 }
584
823
  end
585
824
 
586
- described_class.override(contexts: { test_only: { tries: 1 } })
825
+ described_class.with_override(contexts: { test_only: { tries: 1 } }) do
826
+ described_class.configure do |c|
827
+ c.contexts[:api] = { tries: 5 }
828
+ end
587
829
 
588
- described_class.configure do |c|
589
- c.contexts[:api] = { tries: 5 }
830
+ expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
590
831
  end
591
832
 
592
- expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
593
833
  expect(@tries).to eq(5)
594
834
  end
595
835
 
596
836
  it "raises ArgumentError on invalid override options" do
597
- expect { described_class.override(does_not_exist: 123) }.to raise_error(ArgumentError)
837
+ expect { described_class.with_override(does_not_exist: 123) { :noop } }.to raise_error(ArgumentError)
598
838
  end
599
839
 
600
840
  it "raises ArgumentError on empty override options" do
601
- expect { described_class.override({}) }.to raise_error(ArgumentError, /empty override/)
841
+ expect { described_class.with_override({}) { :noop } }.to raise_error(ArgumentError, /empty override/)
842
+ end
843
+
844
+ it "raises ArgumentError when called without a block" do
845
+ expect { described_class.with_override(tries: 1) }.to raise_error(ArgumentError, /requires a block/)
602
846
  end
603
847
 
604
848
  it "raises ArgumentError on invalid context override options" do
605
- expect { described_class.override(contexts: { api: { does_not_exist: 123 } }) }
849
+ expect { described_class.with_override(contexts: { api: { does_not_exist: 123 } }) { :noop } }
606
850
  .to raise_error(ArgumentError, /does_not_exist is not a valid option/)
607
851
  end
608
852
 
609
- it "does not copy the provided override options" do
610
- opts = { tries: 1 }
611
- described_class.override(opts)
853
+ it "clears the override after the block returns" do
854
+ described_class.with_override(tries: 1) do
855
+ # active here
856
+ end
612
857
 
613
- opts[:tries] = 2
858
+ expect { described_class.retriable(tries: 3) { increment_tries_with_exception } }.to raise_error(StandardError)
859
+ expect(@tries).to eq(3)
860
+ end
614
861
 
615
- expect { described_class.retriable(tries: 10) { increment_tries_with_exception } }.to raise_error(StandardError)
616
- expect(@tries).to eq(2)
862
+ it "clears the override when the block raises" do
863
+ expect do
864
+ described_class.with_override(tries: 1) { raise "boom" }
865
+ end.to raise_error(RuntimeError, "boom")
866
+
867
+ expect { described_class.retriable(tries: 3) { increment_tries_with_exception } }.to raise_error(StandardError)
868
+ expect(@tries).to eq(3)
869
+ end
870
+
871
+ it "returns the block's return value" do
872
+ result = described_class.with_override(tries: 1) { :return_value }
873
+ expect(result).to eq(:return_value)
874
+ end
875
+
876
+ it "restores the outer override when nested blocks exit" do
877
+ tries_seen = []
878
+ handler = ->(_exception, try, _elapsed, _next) { tries_seen << [Thread.current.object_id, try] }
879
+
880
+ described_class.with_override(tries: 2, on_retry: handler) do
881
+ described_class.with_override(tries: 4, on_retry: handler) do
882
+ expect { described_class.retriable { increment_tries_with_exception } }.to raise_error(StandardError)
883
+ end
884
+
885
+ # After the inner block exits, the outer tries: 2 override is restored.
886
+ @tries = 0
887
+ expect { described_class.retriable { increment_tries_with_exception } }.to raise_error(StandardError)
888
+ expect(@tries).to eq(2)
889
+ end
890
+ end
891
+ end
892
+
893
+ context "#with_override thread safety" do
894
+ # Coordinate threads with queues rather than sleep so tests are deterministic.
895
+ # sleep_disabled is already set to true in the top-level before(:each), so
896
+ # retriable calls do not actually sleep between attempts.
897
+
898
+ it "isolates overrides between threads" do
899
+ ready = Queue.new
900
+ proceed = Queue.new
901
+ results = {}
902
+ mutex = Mutex.new
903
+
904
+ threads = [1, 2].map do |id|
905
+ Thread.new do
906
+ described_class.with_override(tries: id) do
907
+ ready << true
908
+ proceed.pop
909
+ tries = 0
910
+ begin
911
+ described_class.retriable do
912
+ tries += 1
913
+ raise StandardError
914
+ end
915
+ rescue StandardError
916
+ mutex.synchronize { results[id] = tries }
917
+ end
918
+ end
919
+ end
920
+ end
921
+
922
+ 2.times { ready.pop }
923
+ 2.times { proceed << true }
924
+ threads.each(&:join)
925
+
926
+ expect(results).to eq(1 => 1, 2 => 2)
927
+ end
928
+
929
+ it "does not leak an active override into a sibling thread" do
930
+ override_active = Queue.new
931
+ sibling_done = Queue.new
932
+ sibling_tries = nil
933
+
934
+ setter = Thread.new do
935
+ described_class.with_override(tries: 1) do
936
+ override_active << true
937
+ sibling_done.pop
938
+ end
939
+ end
940
+
941
+ sibling = Thread.new do
942
+ override_active.pop
943
+ tries = 0
944
+ begin
945
+ described_class.retriable(tries: 3) do
946
+ tries += 1
947
+ raise StandardError
948
+ end
949
+ rescue StandardError
950
+ sibling_tries = tries
951
+ end
952
+ sibling_done << true
953
+ end
954
+
955
+ [setter, sibling].each(&:join)
956
+ expect(sibling_tries).to eq(3)
957
+ end
958
+
959
+ it "does not propagate an active override to a child thread" do
960
+ child_tries = nil
961
+
962
+ described_class.with_override(tries: 1) do
963
+ Thread.new do
964
+ tries = 0
965
+ begin
966
+ described_class.retriable(tries: 3) do
967
+ tries += 1
968
+ raise StandardError
969
+ end
970
+ rescue StandardError
971
+ child_tries = tries
972
+ end
973
+ end.join
974
+ end
975
+
976
+ expect(child_tries).to eq(3)
977
+ end
978
+
979
+ it "shares the active override with fibers in the same thread" do
980
+ fiber_tries = nil
981
+
982
+ Thread.new do
983
+ described_class.with_override(tries: 1) do
984
+ Fiber.new do
985
+ tries = 0
986
+ begin
987
+ described_class.retriable(tries: 10) do
988
+ tries += 1
989
+ raise StandardError
990
+ end
991
+ rescue StandardError
992
+ fiber_tries = tries
993
+ end
994
+ end.resume
995
+ end
996
+ end.join
997
+
998
+ expect(fiber_tries).to eq(1)
999
+ end
1000
+
1001
+ it "does not treat a main-thread override as a global default for other threads" do
1002
+ other_thread_tries = nil
1003
+
1004
+ described_class.with_override(tries: 1) do
1005
+ Thread.new do
1006
+ tries = 0
1007
+ begin
1008
+ described_class.retriable(tries: 3) do
1009
+ tries += 1
1010
+ raise StandardError
1011
+ end
1012
+ rescue StandardError
1013
+ other_thread_tries = tries
1014
+ end
1015
+ end.join
1016
+ end
1017
+
1018
+ expect(other_thread_tries).to eq(3)
617
1019
  end
618
1020
  end
619
1021