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