retriable 3.5.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/main.yml +38 -9
- data/.hound.yml +1 -1
- data/.rubocop.yml +4 -1
- data/CHANGELOG.md +113 -0
- data/Gemfile +4 -1
- data/README.md +197 -110
- data/docs/testing.md +212 -0
- data/lib/retriable/config.rb +81 -10
- data/lib/retriable/core_ext/kernel.rb +6 -4
- data/lib/retriable/exponential_backoff.rb +44 -10
- data/lib/retriable/validation.rb +95 -0
- data/lib/retriable/version.rb +1 -1
- data/lib/retriable.rb +121 -57
- data/retriable.gemspec +2 -7
- data/sig/retriable.rbs +29 -1
- data/spec/config_spec.rb +157 -4
- data/spec/exponential_backoff_spec.rb +45 -26
- data/spec/retriable_spec.rb +739 -87
- data/spec/spec_helper.rb +3 -1
- metadata +9 -53
data/sig/retriable.rbs
CHANGED
|
@@ -1,4 +1,32 @@
|
|
|
1
1
|
module Retriable
|
|
2
2
|
VERSION: String
|
|
3
|
-
|
|
3
|
+
OVERRIDE_THREAD_KEY: Symbol
|
|
4
|
+
|
|
5
|
+
def self.configure: () { (Config) -> void } -> void
|
|
6
|
+
def self.config: () -> Config
|
|
7
|
+
def self.with_override: (Hash[Symbol, untyped] options) { () -> untyped } -> untyped
|
|
8
|
+
def self.with_context: (Symbol context_key, ?Hash[Symbol, untyped] options) { (Integer) -> untyped } -> untyped
|
|
9
|
+
def self.retriable: (?Hash[Symbol, untyped] options) { (Integer) -> untyped } -> untyped
|
|
10
|
+
|
|
11
|
+
class Config
|
|
12
|
+
ATTRIBUTES: Array[Symbol]
|
|
13
|
+
|
|
14
|
+
attr_accessor tries: Numeric
|
|
15
|
+
attr_accessor base_interval: Numeric
|
|
16
|
+
attr_accessor max_interval: Numeric
|
|
17
|
+
attr_accessor rand_factor: Numeric
|
|
18
|
+
attr_accessor multiplier: Numeric
|
|
19
|
+
attr_accessor sleep_disabled: bool
|
|
20
|
+
attr_accessor max_elapsed_time: Numeric?
|
|
21
|
+
attr_accessor intervals: Array[Numeric]?
|
|
22
|
+
attr_accessor on: untyped
|
|
23
|
+
attr_accessor retry_if: untyped
|
|
24
|
+
attr_accessor on_retry: untyped
|
|
25
|
+
attr_accessor on_give_up: untyped
|
|
26
|
+
attr_accessor contexts: Hash[Symbol, untyped]
|
|
27
|
+
|
|
28
|
+
def initialize: (?Hash[Symbol, untyped] opts) -> void
|
|
29
|
+
def to_h: () -> Hash[Symbol, untyped]
|
|
30
|
+
def validate!: () -> void
|
|
31
|
+
end
|
|
4
32
|
end
|
data/spec/config_spec.rb
CHANGED
|
@@ -32,10 +32,6 @@ describe Retriable::Config do
|
|
|
32
32
|
expect(default_config.intervals).to be_nil
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
it "timeout defaults to nil" do
|
|
36
|
-
expect(default_config.timeout).to be_nil
|
|
37
|
-
end
|
|
38
|
-
|
|
39
35
|
it "on defaults to [StandardError]" do
|
|
40
36
|
expect(default_config.on).to eq([StandardError])
|
|
41
37
|
end
|
|
@@ -48,6 +44,10 @@ describe Retriable::Config do
|
|
|
48
44
|
expect(default_config.on_retry).to be_nil
|
|
49
45
|
end
|
|
50
46
|
|
|
47
|
+
it "on_give_up handler defaults to nil" do
|
|
48
|
+
expect(default_config.on_give_up).to be_nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
51
|
it "contexts defaults to {}" do
|
|
52
52
|
expect(default_config.contexts).to eq({})
|
|
53
53
|
end
|
|
@@ -56,4 +56,157 @@ describe Retriable::Config do
|
|
|
56
56
|
it "raises errors on invalid configuration" do
|
|
57
57
|
expect { described_class.new(does_not_exist: 123) }.to raise_error(ArgumentError, /not a valid option/)
|
|
58
58
|
end
|
|
59
|
+
|
|
60
|
+
it "rejects timeout as an unknown option" do
|
|
61
|
+
expect { described_class.new(timeout: 5) }.to raise_error(ArgumentError, /not a valid option/)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "raises errors on invalid timing configuration" do
|
|
65
|
+
expect { described_class.new(rand_factor: 1.1) }.to raise_error(ArgumentError, /rand_factor/)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it "raises errors when intervals is not an array" do
|
|
69
|
+
expect { described_class.new(intervals: "1") }.to raise_error(ArgumentError, /intervals must be an Array/)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it "requires a finite max_elapsed_time when tries is Float::INFINITY" do
|
|
73
|
+
expect { described_class.new(tries: Float::INFINITY, max_elapsed_time: nil) }
|
|
74
|
+
.to raise_error(ArgumentError, /max_elapsed_time must be a finite number/)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it "rejects intervals combined with tries: Float::INFINITY" do
|
|
78
|
+
expect do
|
|
79
|
+
described_class.new(
|
|
80
|
+
tries: Float::INFINITY,
|
|
81
|
+
max_elapsed_time: 60,
|
|
82
|
+
intervals: [0.1, 0.2],
|
|
83
|
+
)
|
|
84
|
+
end.to raise_error(ArgumentError, /intervals cannot be used with tries: Float::INFINITY/)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "accepts tries: Float::INFINITY with a finite max_elapsed_time" do
|
|
88
|
+
expect { described_class.new(tries: Float::INFINITY, max_elapsed_time: 60) }
|
|
89
|
+
.not_to raise_error
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
context "on: option validation" do
|
|
93
|
+
it "accepts a single Exception subclass" do
|
|
94
|
+
expect { described_class.new(on: StandardError) }.not_to raise_error
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "accepts Exception itself" do
|
|
98
|
+
expect { described_class.new(on: Exception) }.not_to raise_error
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it "accepts an array of Exception subclasses" do
|
|
102
|
+
expect { described_class.new(on: [StandardError, RuntimeError]) }.not_to raise_error
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "accepts a Set of Exception subclasses" do
|
|
106
|
+
expect { described_class.new(on: Set[StandardError, RuntimeError]) }.not_to raise_error
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it "rejects a Set containing a non-Exception class" do
|
|
110
|
+
expect { described_class.new(on: Set[StandardError, Kernel]) }
|
|
111
|
+
.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it "accepts a hash with nil pattern values" do
|
|
115
|
+
expect { described_class.new(on: { StandardError => nil }) }.not_to raise_error
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it "accepts a hash with Regexp pattern values" do
|
|
119
|
+
expect { described_class.new(on: { StandardError => /boom/ }) }.not_to raise_error
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it "accepts a hash with Array-of-Regexp pattern values" do
|
|
123
|
+
expect { described_class.new(on: { StandardError => [/a/, /b/] }) }.not_to raise_error
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it "rejects Object as on:" do
|
|
127
|
+
expect { described_class.new(on: Object) }
|
|
128
|
+
.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it "rejects Kernel as on:" do
|
|
132
|
+
expect { described_class.new(on: Kernel) }
|
|
133
|
+
.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
it "rejects an array containing a non-Exception class" do
|
|
137
|
+
expect { described_class.new(on: [StandardError, Kernel]) }
|
|
138
|
+
.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it "rejects a hash key that is not an Exception class" do
|
|
142
|
+
expect { described_class.new(on: { Kernel => nil }) }
|
|
143
|
+
.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it "rejects a hash value that is a String" do
|
|
147
|
+
expect { described_class.new(on: { StandardError => "boom" }) }
|
|
148
|
+
.to raise_error(ArgumentError, /on\[StandardError\] must be nil, a Regexp, or an Array of Regexps/)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
it "rejects a hash value that is an Array containing a non-Regexp" do
|
|
152
|
+
expect { described_class.new(on: { StandardError => [/a/, "b"] }) }
|
|
153
|
+
.to raise_error(ArgumentError, /on\[StandardError\] must be nil, a Regexp, or an Array of Regexps/)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it "rejects a string passed as on:" do
|
|
157
|
+
expect { described_class.new(on: "StandardError") }
|
|
158
|
+
.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it "validates on: even when intervals is provided" do
|
|
162
|
+
expect { described_class.new(intervals: [0.1], on: Object) }
|
|
163
|
+
.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
context "callable option validation" do
|
|
168
|
+
%i[retry_if on_retry on_give_up].each do |opt|
|
|
169
|
+
it "accepts a callable for #{opt}" do
|
|
170
|
+
expect { described_class.new(opt => ->(*) {}) }.not_to raise_error
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it "accepts nil and false for #{opt}" do
|
|
174
|
+
expect { described_class.new(opt => nil) }.not_to raise_error
|
|
175
|
+
expect { described_class.new(opt => false) }.not_to raise_error
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
it "rejects a non-callable truthy value for #{opt}" do
|
|
179
|
+
expect { described_class.new(opt => 5) }.to raise_error(ArgumentError, /#{opt}.*#call/)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
context "context structure validation" do
|
|
185
|
+
it "rejects a context whose options contain a nested :contexts key" do
|
|
186
|
+
expect { described_class.new(contexts: { api: { contexts: {} } }) }
|
|
187
|
+
.to raise_error(ArgumentError, /contexts is not a valid option/)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
it "rejects a context with an unknown option key" do
|
|
191
|
+
expect { described_class.new(contexts: { api: { does_not_exist: 1 } }) }
|
|
192
|
+
.to raise_error(ArgumentError, /does_not_exist is not a valid option/)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it "validates context structure even when intervals is provided" do
|
|
196
|
+
expect { described_class.new(intervals: [0.1], contexts: { api: { contexts: {} } }) }
|
|
197
|
+
.to raise_error(ArgumentError, /contexts is not a valid option/)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
it "accepts a non-Hash context value (treated as empty options)" do
|
|
201
|
+
expect { described_class.new(contexts: { broken: nil }) }.not_to raise_error
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
it "accepts nil contexts" do
|
|
205
|
+
expect { described_class.new(contexts: nil) }.not_to raise_error
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it "accepts a valid context" do
|
|
209
|
+
expect { described_class.new(contexts: { api: { tries: 3, base_interval: 1.0 } }) }.not_to raise_error
|
|
210
|
+
end
|
|
211
|
+
end
|
|
59
212
|
end
|
|
@@ -22,17 +22,19 @@ describe Retriable::ExponentialBackoff do
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
it "generates 10 randomized intervals" do
|
|
25
|
-
expect(described_class.new(tries: 9).intervals).to eq(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
25
|
+
expect(described_class.new(tries: 9).intervals).to eq(
|
|
26
|
+
[
|
|
27
|
+
0.5244067512211441,
|
|
28
|
+
0.9113920238761231,
|
|
29
|
+
1.2406087918999114,
|
|
30
|
+
1.7632403621664823,
|
|
31
|
+
2.338001204738311,
|
|
32
|
+
4.350816718580626,
|
|
33
|
+
5.339852157217869,
|
|
34
|
+
11.889873261212443,
|
|
35
|
+
18.756037881636484,
|
|
36
|
+
],
|
|
37
|
+
)
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
it "generates defined number of intervals" do
|
|
@@ -40,19 +42,23 @@ describe Retriable::ExponentialBackoff do
|
|
|
40
42
|
end
|
|
41
43
|
|
|
42
44
|
it "generates intervals with a defined base interval" do
|
|
43
|
-
expect(described_class.new(base_interval: 1).intervals).to eq(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
expect(described_class.new(base_interval: 1).intervals).to eq(
|
|
46
|
+
[
|
|
47
|
+
1.0488135024422882,
|
|
48
|
+
1.8227840477522461,
|
|
49
|
+
2.4812175837998227,
|
|
50
|
+
],
|
|
51
|
+
)
|
|
48
52
|
end
|
|
49
53
|
|
|
50
54
|
it "generates intervals with a defined multiplier" do
|
|
51
|
-
expect(described_class.new(multiplier: 1).intervals).to eq(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
expect(described_class.new(multiplier: 1).intervals).to eq(
|
|
56
|
+
[
|
|
57
|
+
0.5244067512211441,
|
|
58
|
+
0.607594682584082,
|
|
59
|
+
0.5513816852888495,
|
|
60
|
+
],
|
|
61
|
+
)
|
|
56
62
|
end
|
|
57
63
|
|
|
58
64
|
it "generates intervals with a defined max interval" do
|
|
@@ -60,15 +66,28 @@ describe Retriable::ExponentialBackoff do
|
|
|
60
66
|
end
|
|
61
67
|
|
|
62
68
|
it "generates intervals with a defined rand_factor" do
|
|
63
|
-
expect(described_class.new(rand_factor: 0.2).intervals).to eq(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
69
|
+
expect(described_class.new(rand_factor: 0.2).intervals).to eq(
|
|
70
|
+
[
|
|
71
|
+
0.5097627004884576,
|
|
72
|
+
0.8145568095504492,
|
|
73
|
+
1.1712435167599646,
|
|
74
|
+
],
|
|
75
|
+
)
|
|
68
76
|
end
|
|
69
77
|
|
|
70
78
|
it "generates 10 non-randomized intervals" do
|
|
71
79
|
non_random_intervals = 9.times.inject([0.5]) { |memo, _i| memo + [memo.last * 1.5] }
|
|
72
80
|
expect(described_class.new(tries: 10, rand_factor: 0.0).intervals).to eq(non_random_intervals)
|
|
73
81
|
end
|
|
82
|
+
|
|
83
|
+
it "provides capped intervals lazily" do
|
|
84
|
+
interval_for = described_class.new(
|
|
85
|
+
base_interval: 1.0,
|
|
86
|
+
multiplier: 2.0,
|
|
87
|
+
max_interval: 4.0,
|
|
88
|
+
rand_factor: 0.0,
|
|
89
|
+
).interval_provider
|
|
90
|
+
|
|
91
|
+
expect(Array.new(5) { |index| interval_for.call(index) }).to eq([1.0, 2.0, 4.0, 4.0, 4.0])
|
|
92
|
+
end
|
|
74
93
|
end
|