retriable 3.4.1 → 3.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/main.yml +5 -2
- data/CHANGELOG.md +29 -0
- data/README.md +148 -84
- 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 +141 -22
- data/spec/config_spec.rb +187 -0
- data/spec/exponential_backoff_spec.rb +45 -26
- data/spec/retriable_spec.rb +662 -3
- data/spec/spec_helper.rb +13 -0
- metadata +3 -1
data/spec/retriable_spec.rb
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
require "stringio"
|
|
5
|
+
|
|
3
6
|
describe Retriable do
|
|
4
7
|
let(:time_table_handler) do
|
|
5
8
|
->(_exception, try, _elapsed_time, next_interval) { @next_interval_table[try] = next_interval }
|
|
6
9
|
end
|
|
7
10
|
|
|
8
11
|
before(:each) do
|
|
12
|
+
described_class.instance_variable_set(:@config, nil)
|
|
13
|
+
Thread.current.thread_variable_set(Retriable::OVERRIDE_THREAD_KEY, nil)
|
|
9
14
|
described_class.configure { |c| c.sleep_disabled = true }
|
|
10
15
|
@tries = 0
|
|
11
16
|
@next_interval_table = {}
|
|
@@ -23,7 +28,9 @@ describe Retriable do
|
|
|
23
28
|
|
|
24
29
|
context "global scope extension" do
|
|
25
30
|
it "cannot be called in the global scope without requiring the core_ext/kernel" do
|
|
26
|
-
|
|
31
|
+
script = "require 'retriable'; begin; retriable {}; rescue NoMethodError; exit 0; end; exit 1"
|
|
32
|
+
|
|
33
|
+
expect(system(RbConfig.ruby, "-Ilib", "-e", script)).to be(true)
|
|
27
34
|
end
|
|
28
35
|
|
|
29
36
|
it "can be called once the kernel extension is required" do
|
|
@@ -35,9 +42,18 @@ describe Retriable do
|
|
|
35
42
|
end
|
|
36
43
|
|
|
37
44
|
context "#retriable" do
|
|
45
|
+
it "reuses the singleton config when no local options or overrides are provided" do
|
|
46
|
+
expect(described_class::Config).not_to receive(:new)
|
|
47
|
+
|
|
48
|
+
described_class.retriable { increment_tries }
|
|
49
|
+
expect(@tries).to eq(1)
|
|
50
|
+
end
|
|
51
|
+
|
|
38
52
|
it "raises a LocalJumpError if not given a block" do
|
|
39
53
|
expect { described_class.retriable }.to raise_error(LocalJumpError)
|
|
40
|
-
expect
|
|
54
|
+
expect do
|
|
55
|
+
expect { described_class.retriable(timeout: 2) }.to raise_error(LocalJumpError)
|
|
56
|
+
end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
|
|
41
57
|
end
|
|
42
58
|
|
|
43
59
|
it "stops at first try if the block does not raise an exception" do
|
|
@@ -71,8 +87,170 @@ describe Retriable do
|
|
|
71
87
|
expect(@tries).to eq(10)
|
|
72
88
|
end
|
|
73
89
|
|
|
90
|
+
it "does not prebuild generated intervals before the first successful try" do
|
|
91
|
+
interval_for = ->(_index) { raise "interval should not be used" }
|
|
92
|
+
backoff = instance_double(Retriable::ExponentialBackoff, interval_provider: interval_for)
|
|
93
|
+
allow(Retriable::ExponentialBackoff).to receive(:new).and_call_original
|
|
94
|
+
allow(Retriable::ExponentialBackoff).to receive(:new).with(
|
|
95
|
+
hash_including(:base_interval, :multiplier, :max_interval, :rand_factor),
|
|
96
|
+
).and_return(backoff)
|
|
97
|
+
|
|
98
|
+
described_class.retriable(tries: 1_000_000) { increment_tries }
|
|
99
|
+
|
|
100
|
+
expect(@tries).to eq(1)
|
|
101
|
+
expect(backoff).to have_received(:interval_provider)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it "supports unbounded retries until the block succeeds" do
|
|
105
|
+
described_class.retriable(tries: Float::INFINITY, max_elapsed_time: 60) do
|
|
106
|
+
increment_tries
|
|
107
|
+
raise StandardError if @tries < 5
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
expect(@tries).to eq(5)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it "stops unbounded retries at max_elapsed_time" do
|
|
114
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
115
|
+
timeline = [
|
|
116
|
+
start_time,
|
|
117
|
+
start_time,
|
|
118
|
+
start_time,
|
|
119
|
+
start_time + 0.01,
|
|
120
|
+
start_time + 0.01,
|
|
121
|
+
start_time + 0.02,
|
|
122
|
+
start_time + 0.02,
|
|
123
|
+
]
|
|
124
|
+
allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC) { timeline.shift || timeline.last }
|
|
125
|
+
|
|
126
|
+
expect do
|
|
127
|
+
described_class.retriable(
|
|
128
|
+
tries: Float::INFINITY,
|
|
129
|
+
base_interval: 0.01,
|
|
130
|
+
multiplier: 1.0,
|
|
131
|
+
rand_factor: 0.0,
|
|
132
|
+
sleep_disabled: true,
|
|
133
|
+
max_elapsed_time: 0.015,
|
|
134
|
+
) do
|
|
135
|
+
increment_tries_with_exception
|
|
136
|
+
end
|
|
137
|
+
end.to raise_error(StandardError)
|
|
138
|
+
|
|
139
|
+
expect(@tries).to eq(3)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it "raises ArgumentError when tries is Float::INFINITY without a finite max_elapsed_time" do
|
|
143
|
+
expect do
|
|
144
|
+
described_class.retriable(tries: Float::INFINITY, max_elapsed_time: nil) { increment_tries }
|
|
145
|
+
end.to raise_error(ArgumentError, /max_elapsed_time must be a finite number/)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it "raises ArgumentError when tries is Float::INFINITY with infinite max_elapsed_time" do
|
|
149
|
+
expect do
|
|
150
|
+
described_class.retriable(tries: Float::INFINITY, max_elapsed_time: Float::INFINITY) { increment_tries }
|
|
151
|
+
end.to raise_error(ArgumentError, /max_elapsed_time must be a finite number/)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it "raises ArgumentError when tries is Float::INFINITY with custom intervals" do
|
|
155
|
+
expect do
|
|
156
|
+
described_class.retriable(tries: Float::INFINITY, intervals: [0.1, 0.2], max_elapsed_time: 60) do
|
|
157
|
+
increment_tries_with_exception
|
|
158
|
+
end
|
|
159
|
+
end.to raise_error(ArgumentError, /intervals cannot be used with tries: Float::INFINITY/)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it "raises ArgumentError when tries is Float::NAN" do
|
|
163
|
+
expect do
|
|
164
|
+
described_class.retriable(tries: Float::NAN) { increment_tries }
|
|
165
|
+
end.to raise_error(ArgumentError, /tries/)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it "raises ArgumentError when tries is negative infinity" do
|
|
169
|
+
expect do
|
|
170
|
+
described_class.retriable(tries: -Float::INFINITY) { increment_tries }
|
|
171
|
+
end.to raise_error(ArgumentError, /tries/)
|
|
172
|
+
end
|
|
173
|
+
|
|
74
174
|
it "will timeout after 1 second" do
|
|
75
|
-
expect
|
|
175
|
+
expect do
|
|
176
|
+
expect { described_class.retriable(timeout: 1) { sleep(1.1) } }.to raise_error(Timeout::Error)
|
|
177
|
+
end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
context "timeout: deprecation" do
|
|
181
|
+
it "warns at most once per process across repeated retriable calls" do
|
|
182
|
+
original_stderr = $stderr
|
|
183
|
+
stderr = StringIO.new
|
|
184
|
+
begin
|
|
185
|
+
$stderr = stderr
|
|
186
|
+
|
|
187
|
+
described_class.retriable(timeout: 5) { :noop }
|
|
188
|
+
described_class.retriable(timeout: 5) { :noop }
|
|
189
|
+
described_class.retriable(timeout: 5) { :noop }
|
|
190
|
+
|
|
191
|
+
expect(stderr.string.scan("timeout:` option is deprecated").size).to eq(1)
|
|
192
|
+
ensure
|
|
193
|
+
$stderr = original_stderr
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
it "warns when timeout is passed to retriable" do
|
|
198
|
+
expect do
|
|
199
|
+
described_class.retriable(timeout: 5) { :noop }
|
|
200
|
+
end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
it "keeps applying timeout while deprecated" do
|
|
204
|
+
original_stderr = $stderr
|
|
205
|
+
begin
|
|
206
|
+
$stderr = StringIO.new
|
|
207
|
+
expect do
|
|
208
|
+
described_class.retriable(timeout: 0.05, tries: 1) { sleep(0.5) }
|
|
209
|
+
end.to raise_error(Timeout::Error)
|
|
210
|
+
ensure
|
|
211
|
+
$stderr = original_stderr
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
it "warns when timeout is supplied through with_override" do
|
|
216
|
+
expect do
|
|
217
|
+
described_class.with_override(timeout: 5) do
|
|
218
|
+
described_class.retriable { :noop }
|
|
219
|
+
end
|
|
220
|
+
end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
it "warns when timeout is supplied through configure" do
|
|
224
|
+
original_config = described_class.config
|
|
225
|
+
begin
|
|
226
|
+
expect do
|
|
227
|
+
described_class.configure { |config| config.timeout = 5 }
|
|
228
|
+
described_class.retriable { :noop }
|
|
229
|
+
end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
|
|
230
|
+
ensure
|
|
231
|
+
described_class.configure do |config|
|
|
232
|
+
original_config.to_h.each { |key, value| config.public_send("#{key}=", value) }
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
it "is silenced by Warning[:deprecated] = false", if: WARN_CATEGORY_SUPPORTED do
|
|
238
|
+
original = Warning[:deprecated]
|
|
239
|
+
begin
|
|
240
|
+
Warning[:deprecated] = false
|
|
241
|
+
expect do
|
|
242
|
+
described_class.retriable(timeout: 5) { :noop }
|
|
243
|
+
end.not_to output.to_stderr
|
|
244
|
+
ensure
|
|
245
|
+
Warning[:deprecated] = original
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
it "does not warn when timeout is absent" do
|
|
250
|
+
expect do
|
|
251
|
+
described_class.retriable { :noop }
|
|
252
|
+
end.not_to output.to_stderr
|
|
253
|
+
end
|
|
76
254
|
end
|
|
77
255
|
|
|
78
256
|
it "applies a randomized exponential backoff to each try" do
|
|
@@ -315,6 +493,18 @@ describe Retriable do
|
|
|
315
493
|
expect(@tries).to eq(2)
|
|
316
494
|
end
|
|
317
495
|
|
|
496
|
+
it "does not count skipped sleep intervals against max elapsed time" do
|
|
497
|
+
allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC).and_return(0.0)
|
|
498
|
+
|
|
499
|
+
expect do
|
|
500
|
+
described_class.retriable(tries: 3, base_interval: 1.0, rand_factor: 0.0, max_elapsed_time: 0.1) do
|
|
501
|
+
increment_tries_with_exception
|
|
502
|
+
end
|
|
503
|
+
end.to raise_error(StandardError)
|
|
504
|
+
|
|
505
|
+
expect(@tries).to eq(3)
|
|
506
|
+
end
|
|
507
|
+
|
|
318
508
|
it "retries up to tries limit when max_elapsed_time is nil" do
|
|
319
509
|
expect do
|
|
320
510
|
described_class.retriable(tries: 4, max_elapsed_time: nil) { increment_tries_with_exception }
|
|
@@ -350,6 +540,47 @@ describe Retriable do
|
|
|
350
540
|
it "raises ArgumentError on invalid options" do
|
|
351
541
|
expect { described_class.retriable(does_not_exist: 123) { increment_tries } }.to raise_error(ArgumentError)
|
|
352
542
|
end
|
|
543
|
+
|
|
544
|
+
it "raises ArgumentError when tries is not a positive integer" do
|
|
545
|
+
expect { described_class.retriable(tries: 1.5) { increment_tries } }
|
|
546
|
+
.to raise_error(ArgumentError, /tries/)
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
it "raises ArgumentError when an interval is negative" do
|
|
550
|
+
expect { described_class.retriable(intervals: [-1]) { increment_tries } }
|
|
551
|
+
.to raise_error(ArgumentError, /intervals/)
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
it "raises ArgumentError when configured timing options become invalid" do
|
|
555
|
+
described_class.configure { |config| config.tries = 0 }
|
|
556
|
+
|
|
557
|
+
expect { described_class.retriable { increment_tries } }
|
|
558
|
+
.to raise_error(ArgumentError, /tries/)
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
it "does not validate generated backoff options when intervals are provided" do
|
|
562
|
+
described_class.retriable(intervals: [0], tries: 0, rand_factor: 1.1) { increment_tries }
|
|
563
|
+
|
|
564
|
+
expect(@tries).to eq(1)
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
it "allows an empty interval array as one attempt" do
|
|
568
|
+
expect do
|
|
569
|
+
described_class.retriable(intervals: []) { increment_tries_with_exception }
|
|
570
|
+
end.to raise_error(StandardError)
|
|
571
|
+
|
|
572
|
+
expect(@tries).to eq(1)
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
it "rejects on: Object before invoking the block" do
|
|
576
|
+
block_invoked = false
|
|
577
|
+
|
|
578
|
+
expect do
|
|
579
|
+
described_class.retriable(on: Object) { block_invoked = true }
|
|
580
|
+
end.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
581
|
+
|
|
582
|
+
expect(block_invoked).to be(false)
|
|
583
|
+
end
|
|
353
584
|
end
|
|
354
585
|
|
|
355
586
|
context "#configure" do
|
|
@@ -359,6 +590,7 @@ describe Retriable do
|
|
|
359
590
|
with_context
|
|
360
591
|
configure
|
|
361
592
|
config
|
|
593
|
+
with_override
|
|
362
594
|
]
|
|
363
595
|
|
|
364
596
|
expect(described_class.singleton_methods(false)).to match_array(public_api_methods)
|
|
@@ -369,6 +601,424 @@ describe Retriable do
|
|
|
369
601
|
end
|
|
370
602
|
end
|
|
371
603
|
|
|
604
|
+
context "#with_override" do
|
|
605
|
+
it "takes precedence over both global config and local options" do
|
|
606
|
+
described_class.configure { |c| c.tries = 2 }
|
|
607
|
+
|
|
608
|
+
described_class.with_override(tries: 1) do
|
|
609
|
+
expect { described_class.retriable(tries: 10) { increment_tries_with_exception } }.to raise_error(StandardError)
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
expect(@tries).to eq(1)
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
it "lets override tries take precedence over local intervals" do
|
|
616
|
+
described_class.with_override(tries: 1) do
|
|
617
|
+
expect do
|
|
618
|
+
described_class.retriable(intervals: [0.5, 1.0]) { increment_tries_with_exception }
|
|
619
|
+
end.to raise_error(StandardError)
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
expect(@tries).to eq(1)
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
it "lets override tries take precedence over context intervals" do
|
|
626
|
+
described_class.configure do |c|
|
|
627
|
+
c.contexts[:api] = { intervals: [0.5, 1.0] }
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
described_class.with_override(tries: 1) do
|
|
631
|
+
expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
expect(@tries).to eq(1)
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
it "lets override context tries take precedence over context intervals" do
|
|
638
|
+
described_class.configure do |c|
|
|
639
|
+
c.contexts[:api] = { intervals: [0.5, 1.0] }
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
described_class.with_override(contexts: { api: { tries: 1 } }) do
|
|
643
|
+
expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
expect(@tries).to eq(1)
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
it "replaces hash-valued options instead of deep-merging them" do
|
|
650
|
+
described_class.with_override(on: { NonStandardError => nil }) do
|
|
651
|
+
expect do
|
|
652
|
+
described_class.retriable(on: { StandardError => nil }, tries: 2) { increment_tries_with_exception }
|
|
653
|
+
end.to raise_error(StandardError)
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
expect(@tries).to eq(1)
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
it "can override local intervals with nil to use configured backoff" do
|
|
660
|
+
described_class.configure { |c| c.tries = 3 }
|
|
661
|
+
|
|
662
|
+
described_class.with_override(intervals: nil) do
|
|
663
|
+
expect do
|
|
664
|
+
described_class.retriable(intervals: [0.5, 1.0], on_retry: time_table_handler) do
|
|
665
|
+
increment_tries_with_exception
|
|
666
|
+
end
|
|
667
|
+
end.to raise_error(StandardError)
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
expect(@tries).to eq(3)
|
|
671
|
+
expect(@next_interval_table[1]).to be_between(0.0, 1.0)
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
it "applies override context values after with_context local options" do
|
|
675
|
+
described_class.configure do |c|
|
|
676
|
+
c.contexts[:api] = { tries: 3, base_interval: 1.0 }
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
described_class.with_override(contexts: { api: { tries: 1 } }) do
|
|
680
|
+
described_class.with_context(:api, tries: 10) { increment_tries }
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
expect(@tries).to eq(1)
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
it "can define a context only in override config" do
|
|
687
|
+
described_class.with_override(contexts: { test_only: { tries: 1 } }) do
|
|
688
|
+
described_class.with_context(:test_only) { increment_tries }
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
expect(@tries).to eq(1)
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
it "does not apply context-only overrides to plain retriable calls" do
|
|
695
|
+
described_class.with_override(contexts: { api: { tries: 1 } }) do
|
|
696
|
+
expect { described_class.retriable(tries: 3) { increment_tries_with_exception } }.to raise_error(StandardError)
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
expect(@tries).to eq(3)
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
it "keeps configured context matchers when top-level override values apply" do
|
|
703
|
+
described_class.configure do |c|
|
|
704
|
+
c.contexts[:api] = { tries: 3, on: NonStandardError }
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
described_class.with_override(tries: 1) do
|
|
708
|
+
expect { described_class.with_context(:api) { increment_tries_with_exception(NonStandardError) } }
|
|
709
|
+
.to raise_error(NonStandardError)
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
expect(@tries).to eq(1)
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
it "combines local options with override-only contexts" do
|
|
716
|
+
described_class.with_override(contexts: { api: { tries: 1 } }) do
|
|
717
|
+
expect do
|
|
718
|
+
described_class.with_context(:api, on: NonStandardError) do
|
|
719
|
+
increment_tries_with_exception(NonStandardError)
|
|
720
|
+
end
|
|
721
|
+
end.to raise_error(NonStandardError)
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
expect(@tries).to eq(1)
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
it "reuses configured contexts when override does not include contexts" do
|
|
728
|
+
described_class.configure do |c|
|
|
729
|
+
c.contexts[:api] = { tries: 1 }
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
described_class.with_override(tries: 1) do
|
|
733
|
+
described_class.with_context(:api) { increment_tries }
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
expect(@tries).to eq(1)
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
it "treats non-hash configured contexts as empty when override contexts are hash" do
|
|
740
|
+
begin
|
|
741
|
+
described_class.configure { |c| c.contexts = nil }
|
|
742
|
+
|
|
743
|
+
described_class.with_override(contexts: { api: { tries: 1 } }) do
|
|
744
|
+
described_class.with_context(:api) { increment_tries }
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
expect(@tries).to eq(1)
|
|
748
|
+
ensure
|
|
749
|
+
described_class.configure { |c| c.contexts = {} }
|
|
750
|
+
end
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
it "ignores nil override contexts values in with_context" do
|
|
754
|
+
described_class.configure do |c|
|
|
755
|
+
c.contexts[:api] = { tries: 1 }
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
described_class.with_override(contexts: nil) do
|
|
759
|
+
described_class.with_context(:api) { increment_tries }
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
expect(@tries).to eq(1)
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
it "raises ArgumentError on non-hash override contexts values" do
|
|
766
|
+
block_called = false
|
|
767
|
+
|
|
768
|
+
expect { described_class.with_override(contexts: 123) { block_called = true } }
|
|
769
|
+
.to raise_error(ArgumentError, /contexts must be a Hash or nil/)
|
|
770
|
+
expect(block_called).to be(false)
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
it "raises ArgumentError on non-hash per-context override values" do
|
|
774
|
+
block_called = false
|
|
775
|
+
|
|
776
|
+
expect { described_class.with_override(contexts: { api: 123 }) { block_called = true } }
|
|
777
|
+
.to raise_error(ArgumentError, /contexts\[:api\] must be a Hash/)
|
|
778
|
+
expect(block_called).to be(false)
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
it "preserves outer override after rejected nested override contexts values" do
|
|
782
|
+
described_class.with_override(tries: 2) do
|
|
783
|
+
expect { described_class.with_override(tries: 1, contexts: 123) { :noop } }
|
|
784
|
+
.to raise_error(ArgumentError, /contexts must be a Hash or nil/)
|
|
785
|
+
|
|
786
|
+
expect { described_class.retriable(tries: 10) { increment_tries_with_exception } }
|
|
787
|
+
.to raise_error(StandardError)
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
expect(@tries).to eq(2)
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
it "preserves outer context override after rejected nested per-context values" do
|
|
794
|
+
described_class.configure do |c|
|
|
795
|
+
c.contexts[:api] = { tries: 10 }
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
described_class.with_override(contexts: { api: { tries: 2 } }) do
|
|
799
|
+
expect { described_class.with_override(contexts: { api: 123 }) { :noop } }
|
|
800
|
+
.to raise_error(ArgumentError, /contexts\[:api\] must be a Hash/)
|
|
801
|
+
|
|
802
|
+
expect { described_class.with_context(:api) { increment_tries_with_exception } }
|
|
803
|
+
.to raise_error(StandardError)
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
expect(@tries).to eq(2)
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
it "shows merged context keys in with_context missing-context errors" do
|
|
810
|
+
described_class.configure do |c|
|
|
811
|
+
c.contexts[:configured] = { tries: 2 }
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
described_class.with_override(contexts: { override_only: { tries: 1 } }) do
|
|
815
|
+
expect { described_class.with_context(:missing) { increment_tries } }
|
|
816
|
+
.to raise_error(ArgumentError, /override_only/)
|
|
817
|
+
end
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
it "does not snapshot configured contexts when adding override-only contexts" do
|
|
821
|
+
described_class.configure do |c|
|
|
822
|
+
c.contexts[:api] = { tries: 2 }
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
described_class.with_override(contexts: { test_only: { tries: 1 } }) do
|
|
826
|
+
described_class.configure do |c|
|
|
827
|
+
c.contexts[:api] = { tries: 5 }
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
expect(@tries).to eq(5)
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
it "raises ArgumentError on invalid override options" do
|
|
837
|
+
expect { described_class.with_override(does_not_exist: 123) { :noop } }.to raise_error(ArgumentError)
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
it "raises ArgumentError on empty override options" do
|
|
841
|
+
expect { described_class.with_override({}) { :noop } }.to raise_error(ArgumentError, /empty override/)
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
it "raises ArgumentError when called without a block" do
|
|
845
|
+
expect { described_class.with_override(tries: 1) }.to raise_error(ArgumentError, /requires a block/)
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
it "raises ArgumentError on invalid context override options" do
|
|
849
|
+
expect { described_class.with_override(contexts: { api: { does_not_exist: 123 } }) { :noop } }
|
|
850
|
+
.to raise_error(ArgumentError, /does_not_exist is not a valid option/)
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
it "clears the override after the block returns" do
|
|
854
|
+
described_class.with_override(tries: 1) do
|
|
855
|
+
# active here
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
expect { described_class.retriable(tries: 3) { increment_tries_with_exception } }.to raise_error(StandardError)
|
|
859
|
+
expect(@tries).to eq(3)
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
it "clears the override when the block raises" do
|
|
863
|
+
expect do
|
|
864
|
+
described_class.with_override(tries: 1) { raise "boom" }
|
|
865
|
+
end.to raise_error(RuntimeError, "boom")
|
|
866
|
+
|
|
867
|
+
expect { described_class.retriable(tries: 3) { increment_tries_with_exception } }.to raise_error(StandardError)
|
|
868
|
+
expect(@tries).to eq(3)
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
it "returns the block's return value" do
|
|
872
|
+
result = described_class.with_override(tries: 1) { :return_value }
|
|
873
|
+
expect(result).to eq(:return_value)
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
it "restores the outer override when nested blocks exit" do
|
|
877
|
+
tries_seen = []
|
|
878
|
+
handler = ->(_exception, try, _elapsed, _next) { tries_seen << [Thread.current.object_id, try] }
|
|
879
|
+
|
|
880
|
+
described_class.with_override(tries: 2, on_retry: handler) do
|
|
881
|
+
described_class.with_override(tries: 4, on_retry: handler) do
|
|
882
|
+
expect { described_class.retriable { increment_tries_with_exception } }.to raise_error(StandardError)
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
# After the inner block exits, the outer tries: 2 override is restored.
|
|
886
|
+
@tries = 0
|
|
887
|
+
expect { described_class.retriable { increment_tries_with_exception } }.to raise_error(StandardError)
|
|
888
|
+
expect(@tries).to eq(2)
|
|
889
|
+
end
|
|
890
|
+
end
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
context "#with_override thread safety" do
|
|
894
|
+
# Coordinate threads with queues rather than sleep so tests are deterministic.
|
|
895
|
+
# sleep_disabled is already set to true in the top-level before(:each), so
|
|
896
|
+
# retriable calls do not actually sleep between attempts.
|
|
897
|
+
|
|
898
|
+
it "isolates overrides between threads" do
|
|
899
|
+
ready = Queue.new
|
|
900
|
+
proceed = Queue.new
|
|
901
|
+
results = {}
|
|
902
|
+
mutex = Mutex.new
|
|
903
|
+
|
|
904
|
+
threads = [1, 2].map do |id|
|
|
905
|
+
Thread.new do
|
|
906
|
+
described_class.with_override(tries: id) do
|
|
907
|
+
ready << true
|
|
908
|
+
proceed.pop
|
|
909
|
+
tries = 0
|
|
910
|
+
begin
|
|
911
|
+
described_class.retriable do
|
|
912
|
+
tries += 1
|
|
913
|
+
raise StandardError
|
|
914
|
+
end
|
|
915
|
+
rescue StandardError
|
|
916
|
+
mutex.synchronize { results[id] = tries }
|
|
917
|
+
end
|
|
918
|
+
end
|
|
919
|
+
end
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
2.times { ready.pop }
|
|
923
|
+
2.times { proceed << true }
|
|
924
|
+
threads.each(&:join)
|
|
925
|
+
|
|
926
|
+
expect(results).to eq(1 => 1, 2 => 2)
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
it "does not leak an active override into a sibling thread" do
|
|
930
|
+
override_active = Queue.new
|
|
931
|
+
sibling_done = Queue.new
|
|
932
|
+
sibling_tries = nil
|
|
933
|
+
|
|
934
|
+
setter = Thread.new do
|
|
935
|
+
described_class.with_override(tries: 1) do
|
|
936
|
+
override_active << true
|
|
937
|
+
sibling_done.pop
|
|
938
|
+
end
|
|
939
|
+
end
|
|
940
|
+
|
|
941
|
+
sibling = Thread.new do
|
|
942
|
+
override_active.pop
|
|
943
|
+
tries = 0
|
|
944
|
+
begin
|
|
945
|
+
described_class.retriable(tries: 3) do
|
|
946
|
+
tries += 1
|
|
947
|
+
raise StandardError
|
|
948
|
+
end
|
|
949
|
+
rescue StandardError
|
|
950
|
+
sibling_tries = tries
|
|
951
|
+
end
|
|
952
|
+
sibling_done << true
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
[setter, sibling].each(&:join)
|
|
956
|
+
expect(sibling_tries).to eq(3)
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
it "does not propagate an active override to a child thread" do
|
|
960
|
+
child_tries = nil
|
|
961
|
+
|
|
962
|
+
described_class.with_override(tries: 1) do
|
|
963
|
+
Thread.new do
|
|
964
|
+
tries = 0
|
|
965
|
+
begin
|
|
966
|
+
described_class.retriable(tries: 3) do
|
|
967
|
+
tries += 1
|
|
968
|
+
raise StandardError
|
|
969
|
+
end
|
|
970
|
+
rescue StandardError
|
|
971
|
+
child_tries = tries
|
|
972
|
+
end
|
|
973
|
+
end.join
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
expect(child_tries).to eq(3)
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
it "shares the active override with fibers in the same thread" do
|
|
980
|
+
fiber_tries = nil
|
|
981
|
+
|
|
982
|
+
Thread.new do
|
|
983
|
+
described_class.with_override(tries: 1) do
|
|
984
|
+
Fiber.new do
|
|
985
|
+
tries = 0
|
|
986
|
+
begin
|
|
987
|
+
described_class.retriable(tries: 10) do
|
|
988
|
+
tries += 1
|
|
989
|
+
raise StandardError
|
|
990
|
+
end
|
|
991
|
+
rescue StandardError
|
|
992
|
+
fiber_tries = tries
|
|
993
|
+
end
|
|
994
|
+
end.resume
|
|
995
|
+
end
|
|
996
|
+
end.join
|
|
997
|
+
|
|
998
|
+
expect(fiber_tries).to eq(1)
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
it "does not treat a main-thread override as a global default for other threads" do
|
|
1002
|
+
other_thread_tries = nil
|
|
1003
|
+
|
|
1004
|
+
described_class.with_override(tries: 1) do
|
|
1005
|
+
Thread.new do
|
|
1006
|
+
tries = 0
|
|
1007
|
+
begin
|
|
1008
|
+
described_class.retriable(tries: 3) do
|
|
1009
|
+
tries += 1
|
|
1010
|
+
raise StandardError
|
|
1011
|
+
end
|
|
1012
|
+
rescue StandardError
|
|
1013
|
+
other_thread_tries = tries
|
|
1014
|
+
end
|
|
1015
|
+
end.join
|
|
1016
|
+
end
|
|
1017
|
+
|
|
1018
|
+
expect(other_thread_tries).to eq(3)
|
|
1019
|
+
end
|
|
1020
|
+
end
|
|
1021
|
+
|
|
372
1022
|
context "#with_context" do
|
|
373
1023
|
let(:api_tries) { 4 }
|
|
374
1024
|
|
|
@@ -416,5 +1066,14 @@ describe Retriable do
|
|
|
416
1066
|
it "raises an ArgumentError when the context isn't found" do
|
|
417
1067
|
expect { described_class.with_context(:wtf) { increment_tries } }.to raise_error(ArgumentError, /wtf not found/)
|
|
418
1068
|
end
|
|
1069
|
+
|
|
1070
|
+
it "treats non-Hash context values as empty options" do
|
|
1071
|
+
described_class.configure do |c|
|
|
1072
|
+
c.contexts[:broken] = nil
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1075
|
+
described_class.with_context(:broken) { increment_tries }
|
|
1076
|
+
expect(@tries).to eq(1)
|
|
1077
|
+
end
|
|
419
1078
|
end
|
|
420
1079
|
end
|