retriable 3.4.1 → 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,11 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rbconfig"
4
+ require "stringio"
5
+
3
6
  describe Retriable do
4
7
  let(:time_table_handler) do
5
8
  ->(_exception, try, _elapsed_time, next_interval) { @next_interval_table[try] = next_interval }
6
9
  end
7
10
 
8
11
  before(:each) do
12
+ described_class.instance_variable_set(:@config, nil)
13
+ Thread.current.thread_variable_set(Retriable::OVERRIDE_THREAD_KEY, nil)
9
14
  described_class.configure { |c| c.sleep_disabled = true }
10
15
  @tries = 0
11
16
  @next_interval_table = {}
@@ -23,7 +28,9 @@ describe Retriable do
23
28
 
24
29
  context "global scope extension" do
25
30
  it "cannot be called in the global scope without requiring the core_ext/kernel" do
26
- expect { retriable { puts "should raise NoMethodError" } }.to raise_error(NoMethodError)
31
+ script = "require 'retriable'; begin; retriable {}; rescue NoMethodError; exit 0; end; exit 1"
32
+
33
+ expect(system(RbConfig.ruby, "-Ilib", "-e", script)).to be(true)
27
34
  end
28
35
 
29
36
  it "can be called once the kernel extension is required" do
@@ -35,9 +42,18 @@ describe Retriable do
35
42
  end
36
43
 
37
44
  context "#retriable" do
45
+ it "reuses the singleton config when no local options or overrides are provided" do
46
+ expect(described_class::Config).not_to receive(:new)
47
+
48
+ described_class.retriable { increment_tries }
49
+ expect(@tries).to eq(1)
50
+ end
51
+
38
52
  it "raises a LocalJumpError if not given a block" do
39
53
  expect { described_class.retriable }.to raise_error(LocalJumpError)
40
- 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
41
57
  end
42
58
 
43
59
  it "stops at first try if the block does not raise an exception" do
@@ -71,8 +87,170 @@ describe Retriable do
71
87
  expect(@tries).to eq(10)
72
88
  end
73
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
+
74
174
  it "will timeout after 1 second" do
75
- 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
76
254
  end
77
255
 
78
256
  it "applies a randomized exponential backoff to each try" do
@@ -315,6 +493,18 @@ describe Retriable do
315
493
  expect(@tries).to eq(2)
316
494
  end
317
495
 
496
+ it "does not count skipped sleep intervals against max elapsed time" do
497
+ allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC).and_return(0.0)
498
+
499
+ expect do
500
+ described_class.retriable(tries: 3, base_interval: 1.0, rand_factor: 0.0, max_elapsed_time: 0.1) do
501
+ increment_tries_with_exception
502
+ end
503
+ end.to raise_error(StandardError)
504
+
505
+ expect(@tries).to eq(3)
506
+ end
507
+
318
508
  it "retries up to tries limit when max_elapsed_time is nil" do
319
509
  expect do
320
510
  described_class.retriable(tries: 4, max_elapsed_time: nil) { increment_tries_with_exception }
@@ -350,6 +540,47 @@ describe Retriable do
350
540
  it "raises ArgumentError on invalid options" do
351
541
  expect { described_class.retriable(does_not_exist: 123) { increment_tries } }.to raise_error(ArgumentError)
352
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
353
584
  end
354
585
 
355
586
  context "#configure" do
@@ -359,6 +590,7 @@ describe Retriable do
359
590
  with_context
360
591
  configure
361
592
  config
593
+ with_override
362
594
  ]
363
595
 
364
596
  expect(described_class.singleton_methods(false)).to match_array(public_api_methods)
@@ -369,6 +601,424 @@ describe Retriable do
369
601
  end
370
602
  end
371
603
 
604
+ context "#with_override" do
605
+ it "takes precedence over both global config and local options" do
606
+ described_class.configure { |c| c.tries = 2 }
607
+
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
+
612
+ expect(@tries).to eq(1)
613
+ end
614
+
615
+ it "lets override tries take precedence over local intervals" do
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
621
+
622
+ expect(@tries).to eq(1)
623
+ end
624
+
625
+ it "lets override tries take precedence over context intervals" do
626
+ described_class.configure do |c|
627
+ c.contexts[:api] = { intervals: [0.5, 1.0] }
628
+ end
629
+
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
+
634
+ expect(@tries).to eq(1)
635
+ end
636
+
637
+ it "lets override context tries take precedence over context intervals" do
638
+ described_class.configure do |c|
639
+ c.contexts[:api] = { intervals: [0.5, 1.0] }
640
+ end
641
+
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
+
646
+ expect(@tries).to eq(1)
647
+ end
648
+
649
+ it "replaces hash-valued options instead of deep-merging them" do
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
655
+
656
+ expect(@tries).to eq(1)
657
+ end
658
+
659
+ it "can override local intervals with nil to use configured backoff" do
660
+ described_class.configure { |c| c.tries = 3 }
661
+
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
669
+
670
+ expect(@tries).to eq(3)
671
+ expect(@next_interval_table[1]).to be_between(0.0, 1.0)
672
+ end
673
+
674
+ it "applies override context values after with_context local options" do
675
+ described_class.configure do |c|
676
+ c.contexts[:api] = { tries: 3, base_interval: 1.0 }
677
+ end
678
+
679
+ described_class.with_override(contexts: { api: { tries: 1 } }) do
680
+ described_class.with_context(:api, tries: 10) { increment_tries }
681
+ end
682
+
683
+ expect(@tries).to eq(1)
684
+ end
685
+
686
+ it "can define a context only in override config" do
687
+ described_class.with_override(contexts: { test_only: { tries: 1 } }) do
688
+ described_class.with_context(:test_only) { increment_tries }
689
+ end
690
+
691
+ expect(@tries).to eq(1)
692
+ end
693
+
694
+ it "does not apply context-only overrides to plain retriable calls" do
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
698
+
699
+ expect(@tries).to eq(3)
700
+ end
701
+
702
+ it "keeps configured context matchers when top-level override values apply" do
703
+ described_class.configure do |c|
704
+ c.contexts[:api] = { tries: 3, on: NonStandardError }
705
+ end
706
+
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
+
712
+ expect(@tries).to eq(1)
713
+ end
714
+
715
+ it "combines local options with override-only contexts" do
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
723
+
724
+ expect(@tries).to eq(1)
725
+ end
726
+
727
+ it "reuses configured contexts when override does not include contexts" do
728
+ described_class.configure do |c|
729
+ c.contexts[:api] = { tries: 1 }
730
+ end
731
+
732
+ described_class.with_override(tries: 1) do
733
+ described_class.with_context(:api) { increment_tries }
734
+ end
735
+
736
+ expect(@tries).to eq(1)
737
+ end
738
+
739
+ it "treats non-hash configured contexts as empty when override contexts are hash" do
740
+ begin
741
+ described_class.configure { |c| c.contexts = nil }
742
+
743
+ described_class.with_override(contexts: { api: { tries: 1 } }) do
744
+ described_class.with_context(:api) { increment_tries }
745
+ end
746
+
747
+ expect(@tries).to eq(1)
748
+ ensure
749
+ described_class.configure { |c| c.contexts = {} }
750
+ end
751
+ end
752
+
753
+ it "ignores nil override contexts values in with_context" do
754
+ described_class.configure do |c|
755
+ c.contexts[:api] = { tries: 1 }
756
+ end
757
+
758
+ described_class.with_override(contexts: nil) do
759
+ described_class.with_context(:api) { increment_tries }
760
+ end
761
+
762
+ expect(@tries).to eq(1)
763
+ end
764
+
765
+ it "raises ArgumentError on non-hash override contexts values" do
766
+ block_called = false
767
+
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
772
+
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)
791
+ end
792
+
793
+ it "preserves outer context override after rejected nested per-context values" do
794
+ described_class.configure do |c|
795
+ c.contexts[:api] = { tries: 10 }
796
+ end
797
+
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
805
+
806
+ expect(@tries).to eq(2)
807
+ end
808
+
809
+ it "shows merged context keys in with_context missing-context errors" do
810
+ described_class.configure do |c|
811
+ c.contexts[:configured] = { tries: 2 }
812
+ end
813
+
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
818
+ end
819
+
820
+ it "does not snapshot configured contexts when adding override-only contexts" do
821
+ described_class.configure do |c|
822
+ c.contexts[:api] = { tries: 2 }
823
+ end
824
+
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
829
+
830
+ expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
831
+ end
832
+
833
+ expect(@tries).to eq(5)
834
+ end
835
+
836
+ it "raises ArgumentError on invalid override options" do
837
+ expect { described_class.with_override(does_not_exist: 123) { :noop } }.to raise_error(ArgumentError)
838
+ end
839
+
840
+ it "raises ArgumentError on empty override options" do
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/)
846
+ end
847
+
848
+ it "raises ArgumentError on invalid context override options" do
849
+ expect { described_class.with_override(contexts: { api: { does_not_exist: 123 } }) { :noop } }
850
+ .to raise_error(ArgumentError, /does_not_exist is not a valid option/)
851
+ end
852
+
853
+ it "clears the override after the block returns" do
854
+ described_class.with_override(tries: 1) do
855
+ # active here
856
+ end
857
+
858
+ expect { described_class.retriable(tries: 3) { increment_tries_with_exception } }.to raise_error(StandardError)
859
+ expect(@tries).to eq(3)
860
+ end
861
+
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)
1019
+ end
1020
+ end
1021
+
372
1022
  context "#with_context" do
373
1023
  let(:api_tries) { 4 }
374
1024
 
@@ -416,5 +1066,14 @@ describe Retriable do
416
1066
  it "raises an ArgumentError when the context isn't found" do
417
1067
  expect { described_class.with_context(:wtf) { increment_tries } }.to raise_error(ArgumentError, /wtf not found/)
418
1068
  end
1069
+
1070
+ it "treats non-Hash context values as empty options" do
1071
+ described_class.configure do |c|
1072
+ c.contexts[:broken] = nil
1073
+ end
1074
+
1075
+ described_class.with_context(:broken) { increment_tries }
1076
+ expect(@tries).to eq(1)
1077
+ end
419
1078
  end
420
1079
  end