retriable 3.4.1 → 4.1.1

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