retriable 3.8.0 → 5.0.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,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rbconfig"
4
- require "stringio"
5
4
 
6
5
  describe Retriable do
7
6
  let(:time_table_handler) do
@@ -9,7 +8,10 @@ describe Retriable do
9
8
  end
10
9
 
11
10
  before(:each) do
12
- described_class.instance_variable_set(:@config, nil)
11
+ # Reset to a pristine published snapshot, matching what load-time init
12
+ # publishes. Resetting to nil instead would force #config to carry a
13
+ # nil-guard that only the suite can reach.
14
+ described_class.instance_variable_set(:@config, Retriable::Config.new.freeze)
13
15
  Thread.current.thread_variable_set(Retriable::OVERRIDE_THREAD_KEY, nil)
14
16
  described_class.configure { |c| c.sleep_disabled = true }
15
17
  @tries = 0
@@ -26,6 +28,31 @@ describe Retriable do
26
28
  raise exception_class, "#{exception_class} occurred"
27
29
  end
28
30
 
31
+ # Wall clock appears in the concurrency specs only as a deadlock backstop,
32
+ # never as a timing assertion. Every worker they start finishes in
33
+ # milliseconds, so this budget is approached only when a regression leaves a
34
+ # thread genuinely stuck.
35
+ let(:deadlock_backstop_seconds) { 10 }
36
+
37
+ # Joins each thread within the backstop, failing the example instead of hanging
38
+ # the suite when one never finishes. A bare Thread#join turns a serialization
39
+ # regression into a run that never terminates, which CI reports as a timeout
40
+ # with no failing example to point at.
41
+ #
42
+ # The kill matters for the examples that follow: Ruby releases a Mutex held by
43
+ # a killed thread, so one stuck worker cannot wedge every later `configure`.
44
+ # Kill is asynchronous, so wait briefly for it to land before failing —
45
+ # otherwise the mutex is still held when the next example starts.
46
+ def join_without_deadlock(*threads)
47
+ threads.flatten.compact.each do |thread|
48
+ next if thread.join(deadlock_backstop_seconds)
49
+
50
+ thread.kill
51
+ thread.join(1)
52
+ raise "a worker thread did not finish within #{deadlock_backstop_seconds}s; suspect a deadlock"
53
+ end
54
+ end
55
+
29
56
  context "global scope extension" do
30
57
  it "cannot be called in the global scope without requiring the core_ext/kernel" do
31
58
  script = "require 'retriable'; begin; retriable {}; rescue NoMethodError; exit 0; end; exit 1"
@@ -39,6 +66,49 @@ describe Retriable do
39
66
  expect { retriable { increment_tries_with_exception } }.to raise_error(StandardError)
40
67
  expect(@tries).to eq(3)
41
68
  end
69
+
70
+ it "passes on_give_up through the kernel extension" do
71
+ require_relative "../lib/retriable/core_ext/kernel"
72
+ received_reason = nil
73
+ handler = proc { |_e, _try, _elapsed, _interval, reason| received_reason = reason }
74
+
75
+ expect { retriable(tries: 1, on_give_up: handler) { increment_tries_with_exception } }
76
+ .to raise_error(StandardError)
77
+
78
+ expect(received_reason).to eq(:tries_exhausted)
79
+ end
80
+
81
+ # These two specs lock in the anonymous block forwarding (`&`) semantics
82
+ # across both delegation layers: Kernel#retriable_with_context ->
83
+ # Retriable.with_context. If the `&` is dropped at either layer, the block
84
+ # is not forwarded and the `block_given?` guard in with_context raises
85
+ # ArgumentError instead of running the block.
86
+ it "forwards a block through Kernel#retriable_with_context" do
87
+ require_relative "../lib/retriable/core_ext/kernel"
88
+ Retriable.configure { |c| c.contexts[:sql] = { tries: 1 } }
89
+
90
+ retriable_with_context(:sql) { increment_tries }
91
+
92
+ expect(@tries).to eq(1)
93
+ end
94
+
95
+ it "raises an ArgumentError when Kernel#retriable_with_context is called without a block" do
96
+ require_relative "../lib/retriable/core_ext/kernel"
97
+ Retriable.configure { |c| c.contexts[:sql] = { tries: 1 } }
98
+
99
+ expect { retriable_with_context(:sql) }
100
+ .to raise_error(ArgumentError, /with_context requires a block/)
101
+ expect(@tries).to eq(0)
102
+ end
103
+
104
+ it "is not callable with an explicit receiver" do
105
+ require_relative "../lib/retriable/core_ext/kernel"
106
+
107
+ expect { "foo".retriable { increment_tries } }
108
+ .to raise_error(NoMethodError, /private method/)
109
+ expect { "foo".retriable_with_context(:sql) { increment_tries } }
110
+ .to raise_error(NoMethodError, /private method/)
111
+ end
42
112
  end
43
113
 
44
114
  context "#retriable" do
@@ -51,9 +121,6 @@ describe Retriable do
51
121
 
52
122
  it "raises a LocalJumpError if not given a block" do
53
123
  expect { described_class.retriable }.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
57
124
  end
58
125
 
59
126
  it "stops at first try if the block does not raise an exception" do
@@ -66,6 +133,19 @@ describe Retriable do
66
133
  expect(@tries).to eq(3)
67
134
  end
68
135
 
136
+ it "re-raises StopIteration after the configured tries" do
137
+ failure = StopIteration.new("end of input")
138
+
139
+ expect do
140
+ described_class.retriable(tries: 2) do
141
+ increment_tries
142
+ raise failure
143
+ end
144
+ end.to(raise_error { |error| expect(error).to equal(failure) })
145
+
146
+ expect(@tries).to eq(2)
147
+ end
148
+
69
149
  it "makes only 1 try when exception raised is not descendent of StandardError" do
70
150
  expect do
71
151
  described_class.retriable { increment_tries_with_exception(NonStandardError) }
@@ -171,86 +251,8 @@ describe Retriable do
171
251
  end.to raise_error(ArgumentError, /tries/)
172
252
  end
173
253
 
174
- it "will timeout after 1 second" do
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
254
+ it "rejects timeout as an unknown option" do
255
+ expect { described_class.retriable(timeout: 1) { :noop } }.to raise_error(ArgumentError, /not a valid option/)
254
256
  end
255
257
 
256
258
  it "applies a randomized exponential backoff to each try" do
@@ -296,6 +298,187 @@ describe Retriable do
296
298
  end
297
299
  end
298
300
 
301
+ it "does not call on_retry when explicitly set to nil" do
302
+ callback_called = false
303
+ original_on_retry = described_class.config.on_retry
304
+
305
+ begin
306
+ described_class.configure do |c|
307
+ c.on_retry = proc { |_exception, _try, _elapsed_time, _next_interval| callback_called = true }
308
+ end
309
+
310
+ expect do
311
+ described_class.retriable(on_retry: nil, tries: 3) { increment_tries_with_exception }
312
+ end.to raise_error(StandardError)
313
+
314
+ expect(@tries).to eq(3)
315
+ expect(callback_called).to be(false)
316
+ ensure
317
+ described_class.configure do |c|
318
+ c.on_retry = original_on_retry
319
+ end
320
+ end
321
+ end
322
+
323
+ it "calls on_give_up with max elapsed time details before re-raising" do
324
+ described_class.configure { |c| c.sleep_disabled = false }
325
+ give_up_calls = []
326
+ on_give_up = proc do |exception, try, elapsed_time, next_interval, reason|
327
+ give_up_calls << [exception, try, elapsed_time, next_interval, reason]
328
+ end
329
+
330
+ expect do
331
+ described_class.retriable(
332
+ intervals: [1.0, 1.0],
333
+ max_elapsed_time: 0.5,
334
+ on_give_up: on_give_up,
335
+ ) do
336
+ increment_tries_with_exception
337
+ end
338
+ end.to raise_error(StandardError)
339
+
340
+ exception, try, elapsed_time, next_interval, reason = give_up_calls.fetch(0)
341
+ expect(give_up_calls.size).to eq(1)
342
+ expect(exception).to be_a(StandardError)
343
+ expect(exception.message).to eq("StandardError occurred")
344
+ expect(try).to eq(1)
345
+ expect(elapsed_time).to be >= 0
346
+ expect(next_interval).to eq(1.0)
347
+ expect(reason).to eq(:max_elapsed_time)
348
+ expect(@tries).to eq(1)
349
+ end
350
+
351
+ it "calls on_give_up with tries exhausted details before re-raising" do
352
+ give_up_calls = []
353
+ on_give_up = proc do |exception, try, elapsed_time, next_interval, reason|
354
+ give_up_calls << [exception, try, elapsed_time, next_interval, reason]
355
+ end
356
+
357
+ expect do
358
+ described_class.retriable(tries: 2, on_give_up: on_give_up) { increment_tries_with_exception }
359
+ end.to raise_error(StandardError)
360
+
361
+ exception, try, elapsed_time, next_interval, reason = give_up_calls.fetch(0)
362
+ expect(give_up_calls.size).to eq(1)
363
+ expect(exception).to be_a(StandardError)
364
+ expect(exception.message).to eq("StandardError occurred")
365
+ expect(try).to eq(2)
366
+ expect(elapsed_time).to be >= 0
367
+ expect(next_interval).to be_nil
368
+ expect(reason).to eq(:tries_exhausted)
369
+ expect(@tries).to eq(2)
370
+ end
371
+
372
+ it "does not call on_give_up when the block eventually succeeds" do
373
+ callback_called = false
374
+
375
+ described_class.retriable(tries: 3, on_give_up: proc { callback_called = true }) do
376
+ increment_tries
377
+ raise StandardError if @tries < 2
378
+ end
379
+
380
+ expect(callback_called).to be(false)
381
+ expect(@tries).to eq(2)
382
+ end
383
+
384
+ it "does not call on_give_up for non-retriable exception types" do
385
+ callback_called = false
386
+
387
+ expect do
388
+ described_class.retriable(on_give_up: proc { callback_called = true }) do
389
+ increment_tries_with_exception(NonStandardError)
390
+ end
391
+ end.to raise_error(NonStandardError)
392
+
393
+ expect(callback_called).to be(false)
394
+ expect(@tries).to eq(1)
395
+ end
396
+
397
+ it "does not call on_give_up when retry_if rejects the exception" do
398
+ callback_called = false
399
+
400
+ expect do
401
+ described_class.retriable(
402
+ tries: 3,
403
+ retry_if: ->(_exception) { false },
404
+ on_give_up: proc { callback_called = true },
405
+ ) do
406
+ increment_tries_with_exception
407
+ end
408
+ end.to raise_error(StandardError)
409
+
410
+ expect(callback_called).to be(false)
411
+ expect(@tries).to eq(1)
412
+ end
413
+
414
+ it "does not call on_give_up when explicitly set to false" do
415
+ callback_called = false
416
+ original_on_give_up = described_class.config.on_give_up
417
+
418
+ begin
419
+ described_class.configure do |c|
420
+ c.on_give_up = proc { callback_called = true }
421
+ end
422
+
423
+ expect do
424
+ described_class.retriable(on_give_up: false, tries: 1) { increment_tries_with_exception }
425
+ end.to raise_error(StandardError)
426
+
427
+ expect(callback_called).to be(false)
428
+ ensure
429
+ described_class.configure do |c|
430
+ c.on_give_up = original_on_give_up
431
+ end
432
+ end
433
+ end
434
+
435
+ it "does not call on_give_up when explicitly set to nil" do
436
+ callback_called = false
437
+ original_on_give_up = described_class.config.on_give_up
438
+
439
+ begin
440
+ described_class.configure do |c|
441
+ c.on_give_up = proc { callback_called = true }
442
+ end
443
+
444
+ expect do
445
+ described_class.retriable(on_give_up: nil, tries: 1) { increment_tries_with_exception }
446
+ end.to raise_error(StandardError)
447
+
448
+ expect(callback_called).to be(false)
449
+ ensure
450
+ described_class.configure do |c|
451
+ c.on_give_up = original_on_give_up
452
+ end
453
+ end
454
+ end
455
+
456
+ it "calls on_retry before on_give_up when giving up" do
457
+ events = []
458
+
459
+ expect do
460
+ described_class.retriable(
461
+ tries: 1,
462
+ on_retry: proc { events << :on_retry },
463
+ on_give_up: proc { events << :on_give_up },
464
+ ) do
465
+ increment_tries_with_exception
466
+ end
467
+ end.to raise_error(StandardError)
468
+
469
+ expect(events).to eq(%i[on_retry on_give_up])
470
+ end
471
+
472
+ it "propagates exceptions raised inside on_give_up, replacing the original exception" do
473
+ handler = proc { raise "handler exploded" }
474
+
475
+ expect do
476
+ described_class.retriable(tries: 1, on_give_up: handler) { increment_tries_with_exception }
477
+ end.to raise_error(RuntimeError, "handler exploded")
478
+
479
+ expect(@tries).to eq(1)
480
+ end
481
+
299
482
  context "with rand_factor 0.0 and an on_retry handler" do
300
483
  let(:tries) { 6 }
301
484
  let(:no_rand_timetable) { { 1 => 0.5, 2 => 0.75, 3 => 1.125 } }
@@ -375,6 +558,19 @@ describe Retriable do
375
558
  end
376
559
  end
377
560
 
561
+ context "with a Set :on parameter" do
562
+ it "retries each exception class in the Set" do
563
+ described_class.retriable(on: Set[StandardError, NonStandardError]) do
564
+ increment_tries
565
+
566
+ raise StandardError if @tries == 1
567
+ raise NonStandardError if @tries == 2
568
+ end
569
+
570
+ expect(@tries).to eq(3)
571
+ end
572
+ end
573
+
378
574
  context "with a hash :on parameter" do
379
575
  let(:on_hash) { { NonStandardError => /NonStandardError occurred/ } }
380
576
 
@@ -407,6 +603,20 @@ describe Retriable do
407
603
  expect(@tries).to eq(1)
408
604
  end
409
605
 
606
+ it "does not call on_give_up when exception class matches but message does not" do
607
+ callback_called = false
608
+
609
+ expect do
610
+ described_class.retriable(on: on_hash, on_give_up: proc { callback_called = true }) do
611
+ increment_tries
612
+ raise SecondNonStandardError, "not a match"
613
+ end
614
+ end.to raise_error(SecondNonStandardError, /not a match/)
615
+
616
+ expect(callback_called).to be(false)
617
+ expect(@tries).to eq(1)
618
+ end
619
+
410
620
  it "successfully retries when the values are arrays of exception message patterns" do
411
621
  exceptions = []
412
622
  handler = ->(exception, try, _elapsed_time, _next_interval) { exceptions[try] = exception }
@@ -599,6 +809,274 @@ describe Retriable do
599
809
  it "raises NoMethodError on invalid configuration" do
600
810
  expect { described_class.configure { |c| c.does_not_exist = 123 } }.to raise_error(NoMethodError)
601
811
  end
812
+
813
+ it "returns the configure block's result" do
814
+ result = described_class.configure do |c|
815
+ c.tries = 7
816
+ :configured
817
+ end
818
+
819
+ expect(result).to eq(:configured)
820
+ expect(described_class.config.tries).to eq(7)
821
+ end
822
+ end
823
+
824
+ context "#configure thread safety (copy-on-write)" do
825
+ it "eagerly initializes @config at load time, before any configure/config call" do
826
+ script = "require 'retriable'; " \
827
+ "exit(Retriable.instance_variable_get(:@config).is_a?(Retriable::Config) ? 0 : 1)"
828
+ expect(system(RbConfig.ruby, "-Ilib", "-e", script)).to be(true)
829
+ end
830
+
831
+ it "publishes a new Config object on configure instead of mutating in place" do
832
+ before = described_class.config
833
+ described_class.configure { |c| c.tries = 7 }
834
+ after = described_class.config
835
+
836
+ expect(after).not_to equal(before)
837
+ expect(after.tries).to eq(7)
838
+ expect(before.tries).not_to eq(7)
839
+ end
840
+
841
+ it "keeps an already-captured snapshot stable across later configures" do
842
+ described_class.configure { |c| c.contexts[:sql] = { tries: 1 } }
843
+ snapshot = described_class.config
844
+
845
+ described_class.configure { |c| c.contexts[:http] = { tries: 2 } }
846
+ described_class.configure { |c| c.contexts[:sql][:tries] = 99 }
847
+
848
+ expect(snapshot.contexts).to eq(sql: { tries: 1 })
849
+ end
850
+
851
+ it "publishes a frozen snapshot, so direct mutation fails loudly" do
852
+ described_class.configure { |c| c.contexts[:api] = { tries: 1 } }
853
+
854
+ expect { described_class.config.tries = 99 }.to raise_error(FrozenError)
855
+ expect { described_class.config.contexts[:api][:tries] = 99 }.to raise_error(FrozenError)
856
+ expect(described_class.config.contexts[:api]).to eq(tries: 1)
857
+ end
858
+
859
+ it "still hands the configure block a mutable candidate" do
860
+ expect do
861
+ described_class.configure do |c|
862
+ c.contexts[:api] = { tries: 1 }
863
+ c.contexts[:api][:tries] = 2
864
+ c.on = [StandardError]
865
+ c.on << ArgumentError
866
+ end
867
+ end.not_to raise_error
868
+
869
+ expect(described_class.config.contexts[:api]).to eq(tries: 2)
870
+ end
871
+
872
+ it "does not freeze collections the caller still owns" do
873
+ caller_owned = [StandardError]
874
+
875
+ described_class.configure { |c| c.on = caller_owned }
876
+
877
+ expect(caller_owned).not_to be_frozen
878
+ expect(described_class.config.on).to be_frozen
879
+ expect(described_class.config.on).not_to equal(caller_owned)
880
+ end
881
+
882
+ it "owns and freezes a copy of a mutable Hash default" do
883
+ fallback = []
884
+ contexts = Hash.new(fallback)
885
+ contexts[:api] = { tries: 1 }
886
+
887
+ described_class.configure { |c| c.contexts = contexts }
888
+ published_default = described_class.config.contexts.default
889
+
890
+ expect(published_default).not_to equal(fallback)
891
+ expect(published_default).to be_frozen
892
+ expect(fallback).not_to be_frozen
893
+ end
894
+
895
+ it "does not let a published default leak mutations into the caller's object" do
896
+ fallback = []
897
+ described_class.configure { |c| c.contexts = Hash.new(fallback) }
898
+
899
+ expect { described_class.config.contexts[:absent] << :leaked }.to raise_error(FrozenError)
900
+ expect(fallback).to be_empty
901
+ end
902
+
903
+ it "does not drop updates when configured concurrently from many threads" do
904
+ keys = (0...50).map { |i| :"ctx_#{i}" }
905
+ release = Queue.new
906
+
907
+ threads = keys.map do |key|
908
+ Thread.new do
909
+ release.pop
910
+ described_class.configure { |c| c.contexts[key] = { tries: 1 } }
911
+ end
912
+ end
913
+
914
+ keys.size.times { release << true }
915
+ join_without_deadlock(threads)
916
+
917
+ expect(described_class.config.contexts.keys).to match_array(keys)
918
+ end
919
+
920
+ it "lets nested configure calls join the outer transaction" do
921
+ inner_candidate = nil
922
+
923
+ result = described_class.configure do |outer|
924
+ outer.tries = 7
925
+ nested_result = described_class.configure do |inner|
926
+ inner_candidate = inner
927
+ inner.base_interval = 1
928
+ :nested_result
929
+ end
930
+
931
+ expect(nested_result).to eq(:nested_result)
932
+ expect(inner_candidate).to equal(outer)
933
+ :outer_result
934
+ end
935
+
936
+ expect(result).to eq(:outer_result)
937
+ expect(described_class.config.tries).to eq(7)
938
+ expect(described_class.config.base_interval).to eq(1)
939
+ end
940
+
941
+ it "rolls back nested changes when the outer configure raises" do
942
+ described_class.configure { |c| c.tries = 4 }
943
+
944
+ expect do
945
+ described_class.configure do |outer|
946
+ outer.tries = 7
947
+ described_class.configure { |inner| inner.base_interval = 1 }
948
+ raise "outer failed"
949
+ end
950
+ end.to raise_error(RuntimeError, "outer failed")
951
+
952
+ expect(described_class.config.tries).to eq(4)
953
+ expect(described_class.config.base_interval).to eq(0.5)
954
+ end
955
+
956
+ it "lets configure calls from a fiber join the outer transaction" do
957
+ described_class.configure do |outer|
958
+ fiber_candidate = Fiber.new do
959
+ described_class.configure do |inner|
960
+ inner.tries = 9
961
+ inner
962
+ end
963
+ end.resume
964
+
965
+ expect(fiber_candidate).to equal(outer)
966
+ end
967
+
968
+ expect(described_class.config.tries).to eq(9)
969
+ end
970
+
971
+ it "exposes the in-progress config to the configuring thread" do
972
+ described_class.configure do |c|
973
+ c.tries = 7
974
+ expect(described_class.config.tries).to eq(7)
975
+ end
976
+
977
+ expect(described_class.config.tries).to eq(7)
978
+ end
979
+
980
+ it "uses the in-progress config for retriable" do
981
+ attempts = 0
982
+
983
+ described_class.configure do |c|
984
+ c.tries = 1
985
+ c.sleep_disabled = true
986
+
987
+ expect do
988
+ described_class.retriable do
989
+ attempts += 1
990
+ raise StandardError
991
+ end
992
+ end.to raise_error(StandardError)
993
+ end
994
+
995
+ expect(attempts).to eq(1)
996
+ end
997
+
998
+ it "uses candidate-only contexts for with_context" do
999
+ described_class.configure do |c|
1000
+ c.contexts[:candidate] = { tries: 1 }
1001
+
1002
+ expect(described_class.with_context(:candidate) { :found }).to eq(:found)
1003
+ end
1004
+ end
1005
+
1006
+ it "shares the in-progress config with fibers in the configuring thread" do
1007
+ described_class.configure do |c|
1008
+ c.tries = 7
1009
+
1010
+ expect(Fiber.new { described_class.config.tries }.resume).to eq(7)
1011
+ end
1012
+ end
1013
+
1014
+ it "does not block readers while configure is in progress" do
1015
+ published_tries = described_class.config.tries
1016
+ published_base_interval = described_class.config.base_interval
1017
+ configuring = Queue.new
1018
+ release = Queue.new
1019
+ writer = Thread.new do
1020
+ described_class.configure do |c|
1021
+ c.tries = published_tries + 1
1022
+ described_class.configure { |inner| inner.base_interval = published_base_interval + 1 }
1023
+ configuring << true
1024
+ release.pop
1025
+ end
1026
+ end
1027
+ reader = nil
1028
+
1029
+ begin
1030
+ configuring.pop
1031
+ reader = Thread.new do
1032
+ snapshot = described_class.config
1033
+ [snapshot.tries, snapshot.base_interval]
1034
+ end
1035
+ # The timeout is a deadlock backstop, not the assertion. A reader that
1036
+ # is not blocked returns immediately, so this budget is only ever
1037
+ # approached if #config starts waiting on the in-progress #configure.
1038
+ expect(reader.join(deadlock_backstop_seconds)).to be(reader),
1039
+ "reader blocked while configure was in progress"
1040
+ # Thread#value re-raises anything the reader raised, so a broken reader
1041
+ # fails loudly instead of hanging on an empty queue.
1042
+ expect(reader.value).to eq([published_tries, published_base_interval])
1043
+ ensure
1044
+ release << true
1045
+ join_without_deadlock(writer, reader)
1046
+ end
1047
+ end
1048
+ end
1049
+
1050
+ context "#retriable tries/intervals precedence" do
1051
+ it "lets a per-call tries clear globally configured intervals" do
1052
+ described_class.configure { |c| c.intervals = [0.5, 1.0] }
1053
+
1054
+ expect do
1055
+ described_class.retriable(tries: 1) { increment_tries_with_exception }
1056
+ end.to raise_error(StandardError)
1057
+
1058
+ expect(@tries).to eq(1)
1059
+ end
1060
+
1061
+ it "still lets per-call intervals win when both intervals and tries are given" do
1062
+ expect do
1063
+ described_class.retriable(intervals: [0.5, 1.0], tries: 1) { increment_tries_with_exception }
1064
+ end.to raise_error(StandardError)
1065
+
1066
+ expect(@tries).to eq(3) # intervals.size + 1
1067
+ end
1068
+
1069
+ it "lets a with_context tries clear context intervals" do
1070
+ described_class.configure do |c|
1071
+ c.contexts[:api] = { intervals: [0.5, 1.0] }
1072
+ end
1073
+
1074
+ expect do
1075
+ described_class.with_context(:api, tries: 1) { increment_tries_with_exception }
1076
+ end.to raise_error(StandardError)
1077
+
1078
+ expect(@tries).to eq(1)
1079
+ end
602
1080
  end
603
1081
 
604
1082
  context "#with_override" do
@@ -737,17 +1215,15 @@ describe Retriable do
737
1215
  end
738
1216
 
739
1217
  it "treats non-hash configured contexts as empty when override contexts are hash" do
740
- begin
741
- described_class.configure { |c| c.contexts = nil }
1218
+ described_class.configure { |c| c.contexts = nil }
742
1219
 
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 = {} }
1220
+ described_class.with_override(contexts: { api: { tries: 1 } }) do
1221
+ described_class.with_context(:api) { increment_tries }
750
1222
  end
1223
+
1224
+ expect(@tries).to eq(1)
1225
+ ensure
1226
+ described_class.configure { |c| c.contexts = {} }
751
1227
  end
752
1228
 
753
1229
  it "ignores nil override contexts values in with_context" do
@@ -921,7 +1397,7 @@ describe Retriable do
921
1397
 
922
1398
  2.times { ready.pop }
923
1399
  2.times { proceed << true }
924
- threads.each(&:join)
1400
+ join_without_deadlock(threads)
925
1401
 
926
1402
  expect(results).to eq(1 => 1, 2 => 2)
927
1403
  end
@@ -952,7 +1428,7 @@ describe Retriable do
952
1428
  sibling_done << true
953
1429
  end
954
1430
 
955
- [setter, sibling].each(&:join)
1431
+ join_without_deadlock(setter, sibling)
956
1432
  expect(sibling_tries).to eq(3)
957
1433
  end
958
1434
 
@@ -960,7 +1436,7 @@ describe Retriable do
960
1436
  child_tries = nil
961
1437
 
962
1438
  described_class.with_override(tries: 1) do
963
- Thread.new do
1439
+ child = Thread.new do
964
1440
  tries = 0
965
1441
  begin
966
1442
  described_class.retriable(tries: 3) do
@@ -970,7 +1446,8 @@ describe Retriable do
970
1446
  rescue StandardError
971
1447
  child_tries = tries
972
1448
  end
973
- end.join
1449
+ end
1450
+ join_without_deadlock(child)
974
1451
  end
975
1452
 
976
1453
  expect(child_tries).to eq(3)
@@ -979,7 +1456,7 @@ describe Retriable do
979
1456
  it "shares the active override with fibers in the same thread" do
980
1457
  fiber_tries = nil
981
1458
 
982
- Thread.new do
1459
+ worker = Thread.new do
983
1460
  described_class.with_override(tries: 1) do
984
1461
  Fiber.new do
985
1462
  tries = 0
@@ -993,7 +1470,8 @@ describe Retriable do
993
1470
  end
994
1471
  end.resume
995
1472
  end
996
- end.join
1473
+ end
1474
+ join_without_deadlock(worker)
997
1475
 
998
1476
  expect(fiber_tries).to eq(1)
999
1477
  end
@@ -1002,7 +1480,7 @@ describe Retriable do
1002
1480
  other_thread_tries = nil
1003
1481
 
1004
1482
  described_class.with_override(tries: 1) do
1005
- Thread.new do
1483
+ other = Thread.new do
1006
1484
  tries = 0
1007
1485
  begin
1008
1486
  described_class.retriable(tries: 3) do
@@ -1012,11 +1490,37 @@ describe Retriable do
1012
1490
  rescue StandardError
1013
1491
  other_thread_tries = tries
1014
1492
  end
1015
- end.join
1493
+ end
1494
+ join_without_deadlock(other)
1016
1495
  end
1017
1496
 
1018
1497
  expect(other_thread_tries).to eq(3)
1019
1498
  end
1499
+
1500
+ it "applies overridden on_give_up handlers" do
1501
+ callback_called = false
1502
+
1503
+ expect do
1504
+ described_class.with_override(on_give_up: proc { callback_called = true }) do
1505
+ described_class.retriable(tries: 1) { increment_tries_with_exception }
1506
+ end
1507
+ end.to raise_error(StandardError)
1508
+
1509
+ expect(callback_called).to be(true)
1510
+ end
1511
+
1512
+ it "applies on_give_up handlers configured via per-context overrides" do
1513
+ received_reason = nil
1514
+ handler = proc { |_e, _try, _elapsed, _interval, reason| received_reason = reason }
1515
+
1516
+ expect do
1517
+ described_class.with_override(contexts: { api: { tries: 1, on_give_up: handler } }) do
1518
+ described_class.with_context(:api) { increment_tries_with_exception }
1519
+ end
1520
+ end.to raise_error(StandardError)
1521
+
1522
+ expect(received_reason).to eq(:tries_exhausted)
1523
+ end
1020
1524
  end
1021
1525
 
1022
1526
  context "#with_context" do
@@ -1034,8 +1538,15 @@ describe Retriable do
1034
1538
  expect(@tries).to eq(1)
1035
1539
  end
1036
1540
 
1037
- it "returns nil when called without a block" do
1038
- expect(described_class.with_context(:sql)).to be_nil
1541
+ it "raises an ArgumentError when called without a block" do
1542
+ expect { described_class.with_context(:sql) }
1543
+ .to raise_error(ArgumentError, /with_context requires a block/)
1544
+ expect(@tries).to eq(0)
1545
+ end
1546
+
1547
+ it "checks for a block before looking up the context" do
1548
+ expect { described_class.with_context(:missing) }
1549
+ .to raise_error(ArgumentError, /with_context requires a block/)
1039
1550
  expect(@tries).to eq(0)
1040
1551
  end
1041
1552
 
@@ -1075,5 +1586,56 @@ describe Retriable do
1075
1586
  described_class.with_context(:broken) { increment_tries }
1076
1587
  expect(@tries).to eq(1)
1077
1588
  end
1589
+
1590
+ it "surfaces an invalid context on any retriable call before that context is used" do
1591
+ described_class.configure { |c| c.contexts[:unused] = { contexts: {} } }
1592
+
1593
+ expect { described_class.retriable { :ok } }
1594
+ .to raise_error(ArgumentError, /contexts is not a valid option/)
1595
+ end
1596
+
1597
+ it "invokes on_give_up configured on a context" do
1598
+ callback_called = false
1599
+ described_class.configure do |c|
1600
+ c.contexts[:flaky] = { tries: 1, on_give_up: proc { callback_called = true } }
1601
+ end
1602
+
1603
+ expect { described_class.with_context(:flaky) { increment_tries_with_exception } }
1604
+ .to raise_error(StandardError)
1605
+
1606
+ expect(callback_called).to be(true)
1607
+ end
1608
+
1609
+ it "resolves the context against a single config snapshot" do
1610
+ with_ctx = Retriable::Config.new(sleep_disabled: true, contexts: { api: { tries: 1 } })
1611
+ without_ctx = Retriable::Config.new(sleep_disabled: true)
1612
+
1613
+ # Simulate a concurrent #configure publishing a new config between
1614
+ # with_context's existence check and its option resolution: the first
1615
+ # config read sees the context, later reads do not. with_context must
1616
+ # read config once so the context options are never silently dropped.
1617
+ allow(described_class).to receive(:config).and_return(with_ctx, without_ctx)
1618
+
1619
+ expect { described_class.with_context(:api) { increment_tries_with_exception } }
1620
+ .to raise_error(StandardError)
1621
+
1622
+ expect(@tries).to eq(1)
1623
+ end
1624
+
1625
+ it "resolves global options against the same snapshot used for the context" do
1626
+ special_error = Class.new(StandardError)
1627
+ with_ctx = Retriable::Config.new(sleep_disabled: true, on: [special_error], contexts: { api: { tries: 2 } })
1628
+ swapped = Retriable::Config.new(sleep_disabled: true, on: [ArgumentError])
1629
+
1630
+ # A concurrent #configure swaps the global config (here, the retriable
1631
+ # `on` list) after with_context has captured its snapshot. The whole
1632
+ # context execution must use the captured snapshot, not the swapped one.
1633
+ allow(described_class).to receive(:config).and_return(with_ctx, swapped)
1634
+
1635
+ expect { described_class.with_context(:api) { increment_tries_with_exception(special_error) } }
1636
+ .to raise_error(special_error)
1637
+
1638
+ expect(@tries).to eq(2)
1639
+ end
1078
1640
  end
1079
1641
  end