retriable 3.4.1 → 4.2.0

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