retriable 3.4.1 → 4.1.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.
- checksums.yaml +4 -4
- data/.github/workflows/main.yml +43 -11
- data/.hound.yml +1 -1
- data/.rubocop.yml +4 -1
- data/CHANGELOG.md +77 -0
- data/Gemfile +4 -1
- data/README.md +204 -95
- data/docs/testing.md +212 -0
- data/lib/retriable/config.rb +56 -4
- data/lib/retriable/core_ext/kernel.rb +4 -4
- data/lib/retriable/exponential_backoff.rb +31 -5
- data/lib/retriable/validation.rb +95 -0
- data/lib/retriable/version.rb +1 -1
- data/lib/retriable.rb +180 -40
- data/retriable.gemspec +2 -7
- data/sig/retriable.rbs +29 -1
- data/spec/config_spec.rb +128 -4
- data/spec/exponential_backoff_spec.rb +45 -26
- data/spec/retriable_spec.rb +889 -4
- data/spec/spec_helper.rb +3 -1
- metadata +8 -52
data/docs/testing.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# Testing with Retriable
|
|
2
|
+
|
|
3
|
+
`Retriable.with_override` exists primarily for tests. It lets a test force
|
|
4
|
+
retry options like `tries: 1` or `base_interval: 0` so the suite runs quickly
|
|
5
|
+
and predictably, regardless of what the application's `Retriable.configure`
|
|
6
|
+
defaults are.
|
|
7
|
+
|
|
8
|
+
`with_override` is block-scoped: the override is active inside the block and
|
|
9
|
+
restored to its previous value (which is usually "no override") when the block
|
|
10
|
+
exits, even if the block raises. It is also thread-local — overrides set in
|
|
11
|
+
one thread do not affect other threads — so it is safe for parallel test
|
|
12
|
+
runners. See the README for the full API contract.
|
|
13
|
+
|
|
14
|
+
## RSpec
|
|
15
|
+
|
|
16
|
+
### Apply an override to every test
|
|
17
|
+
|
|
18
|
+
Use `around(:each)` in `RSpec.configure` so every test in the suite runs inside
|
|
19
|
+
the override. This is the most common pattern:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
RSpec.configure do |config|
|
|
23
|
+
config.around(:each) do |example|
|
|
24
|
+
Retriable.with_override(tries: 1, base_interval: 0) do
|
|
25
|
+
example.run
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Apply an override to a specific context
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
describe MyClient do
|
|
35
|
+
context "when external calls should not retry" do
|
|
36
|
+
around(:each) do |example|
|
|
37
|
+
Retriable.with_override(tries: 1) { example.run }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it "fails fast" do
|
|
41
|
+
# `with_override(tries: 1)` is active here
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Apply an override to a single test
|
|
48
|
+
|
|
49
|
+
Wrap the test body directly:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
it "does the thing without waiting" do
|
|
53
|
+
Retriable.with_override(tries: 1, base_interval: 0) do
|
|
54
|
+
# test body
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Reusable helper
|
|
60
|
+
|
|
61
|
+
Wrap a common configuration in a helper to keep tests readable:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
module RetriableHelpers
|
|
65
|
+
def with_fast_retries(&block)
|
|
66
|
+
Retriable.with_override(tries: 1, base_interval: 0, &block)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
RSpec.configure do |config|
|
|
71
|
+
config.include RetriableHelpers
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# In a spec:
|
|
75
|
+
it "does the thing" do
|
|
76
|
+
with_fast_retries do
|
|
77
|
+
# test body
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Minitest
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
class MyClientTest < Minitest::Test
|
|
86
|
+
def around
|
|
87
|
+
Retriable.with_override(tries: 1, base_interval: 0) { yield }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def test_fails_fast
|
|
91
|
+
# `with_override(tries: 1)` is active here
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Older Minitest versions without `around` can wrap the test body directly:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
def test_fails_fast
|
|
100
|
+
Retriable.with_override(tries: 1) do
|
|
101
|
+
# test body
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Short-Circuiting Retriable in Your Test Suite
|
|
107
|
+
|
|
108
|
+
When you are running tests for your app, the default retry behavior (3 tries
|
|
109
|
+
with exponential backoff) makes failing blocks take a long time. To short-circuit
|
|
110
|
+
retries — including calls that pass local options — set `tries: 1` and disable
|
|
111
|
+
backoff using `with_override`.
|
|
112
|
+
|
|
113
|
+
### Under Rails
|
|
114
|
+
|
|
115
|
+
Keep shared defaults in `Retriable.configure` and apply test-only overrides via
|
|
116
|
+
RSpec's `around` hook (or your test framework's equivalent):
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
# config/initializers/retriable.rb
|
|
120
|
+
Retriable.configure do |c|
|
|
121
|
+
c.tries = 3
|
|
122
|
+
c.base_interval = 0.5
|
|
123
|
+
c.rand_factor = 0.5
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# spec/spec_helper.rb (or equivalent)
|
|
127
|
+
RSpec.configure do |config|
|
|
128
|
+
config.around(:each) do |example|
|
|
129
|
+
Retriable.with_override(tries: 1, base_interval: 0, rand_factor: 0) do
|
|
130
|
+
example.run
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
If a specific test needs normal retry behavior, opt out by running outside the
|
|
137
|
+
`around` hook. The cleanest way is to tag the example and skip the hook for
|
|
138
|
+
tagged examples:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
config.around(:each, retriable: :real) { |example| example.run }
|
|
142
|
+
config.around(:each) do |example|
|
|
143
|
+
next example.run if example.metadata[:retriable] == :real
|
|
144
|
+
|
|
145
|
+
Retriable.with_override(tries: 1, base_interval: 0, rand_factor: 0) do
|
|
146
|
+
example.run
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it "exercises the real retry behavior", retriable: :real do
|
|
151
|
+
# `with_override` is not applied here
|
|
152
|
+
end
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Overriding Configured Contexts in Tests
|
|
156
|
+
|
|
157
|
+
If you have configured contexts, top-level override values (such as `tries: 1`)
|
|
158
|
+
already take precedence over context-specific values. To override
|
|
159
|
+
context-specific options as well (for example, clearing a context's
|
|
160
|
+
`:intervals` array or shrinking its `:on` exception list), pass `:contexts` to
|
|
161
|
+
`with_override`.
|
|
162
|
+
|
|
163
|
+
Given a configured `google_api` context:
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
# config/initializers/retriable.rb
|
|
167
|
+
Retriable.configure do |c|
|
|
168
|
+
c.contexts[:google_api] = {
|
|
169
|
+
tries: 5,
|
|
170
|
+
base_interval: 3,
|
|
171
|
+
on: [
|
|
172
|
+
Net::ReadTimeout,
|
|
173
|
+
Signet::AuthorizationError,
|
|
174
|
+
Errno::ECONNRESET,
|
|
175
|
+
OpenSSL::SSL::SSLError,
|
|
176
|
+
],
|
|
177
|
+
}
|
|
178
|
+
end
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
You can override both top-level defaults and per-context options in your
|
|
182
|
+
test setup:
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
RSpec.configure do |config|
|
|
186
|
+
config.around(:each) do |example|
|
|
187
|
+
context_overrides = Retriable.config.contexts.each_key.with_object({}) do |key, h|
|
|
188
|
+
h[key] = { tries: 1, base_interval: 0 }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
Retriable.with_override(
|
|
192
|
+
multiplier: 1.0,
|
|
193
|
+
rand_factor: 0.0,
|
|
194
|
+
base_interval: 0,
|
|
195
|
+
contexts: context_overrides,
|
|
196
|
+
) do
|
|
197
|
+
example.run
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Notes
|
|
204
|
+
|
|
205
|
+
- The override is automatically cleared when the block exits, including when
|
|
206
|
+
the block raises. You do not need to clean up after the block.
|
|
207
|
+
- `with_override` calls nest: an inner block temporarily replaces the active
|
|
208
|
+
override, and the outer override is restored when the inner block exits.
|
|
209
|
+
- Overrides are thread-local. Child threads spawned inside the block do not
|
|
210
|
+
inherit it. If a test spawns background threads that themselves call
|
|
211
|
+
`Retriable.retriable`, wrap each background thread's body in its own
|
|
212
|
+
`with_override` call.
|
data/lib/retriable/config.rb
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "exponential_backoff"
|
|
4
|
+
require_relative "validation"
|
|
4
5
|
|
|
5
6
|
module Retriable
|
|
6
7
|
class Config
|
|
8
|
+
include Validation
|
|
9
|
+
|
|
7
10
|
ATTRIBUTES = (ExponentialBackoff::ATTRIBUTES + %i[
|
|
8
11
|
sleep_disabled
|
|
9
12
|
max_elapsed_time
|
|
10
13
|
intervals
|
|
11
|
-
timeout
|
|
12
14
|
on
|
|
13
15
|
retry_if
|
|
14
16
|
on_retry
|
|
17
|
+
on_give_up
|
|
15
18
|
contexts
|
|
16
19
|
]).freeze
|
|
17
20
|
|
|
@@ -28,10 +31,10 @@ module Retriable
|
|
|
28
31
|
@sleep_disabled = false
|
|
29
32
|
@max_elapsed_time = 900 # 15 min
|
|
30
33
|
@intervals = nil
|
|
31
|
-
@timeout = nil
|
|
32
34
|
@on = [StandardError]
|
|
33
35
|
@retry_if = nil
|
|
34
36
|
@on_retry = nil
|
|
37
|
+
@on_give_up = nil
|
|
35
38
|
@contexts = {}
|
|
36
39
|
|
|
37
40
|
opts.each do |k, v|
|
|
@@ -39,12 +42,61 @@ module Retriable
|
|
|
39
42
|
|
|
40
43
|
instance_variable_set(:"@#{k}", v)
|
|
41
44
|
end
|
|
45
|
+
|
|
46
|
+
validate!
|
|
42
47
|
end
|
|
43
48
|
|
|
44
49
|
def to_h
|
|
45
|
-
ATTRIBUTES.
|
|
46
|
-
|
|
50
|
+
ATTRIBUTES.to_h { |key| [key, public_send(key)] }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate!
|
|
54
|
+
validate_callable(:retry_if, retry_if)
|
|
55
|
+
validate_callable(:on_retry, on_retry)
|
|
56
|
+
validate_callable(:on_give_up, on_give_up)
|
|
57
|
+
validate_on(on)
|
|
58
|
+
validate_intervals
|
|
59
|
+
if unbounded_tries?(tries)
|
|
60
|
+
validate_unbounded_tries
|
|
61
|
+
else
|
|
62
|
+
validate_optional_non_negative_number(:max_elapsed_time, max_elapsed_time)
|
|
63
|
+
return if intervals
|
|
64
|
+
|
|
65
|
+
validate_positive_integer(:tries, tries)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
validate_backoff_options
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def validate_backoff_options
|
|
74
|
+
validate_non_negative_number(:base_interval, base_interval)
|
|
75
|
+
validate_non_negative_number(:multiplier, multiplier)
|
|
76
|
+
validate_non_negative_number(:max_interval, max_interval)
|
|
77
|
+
validate_rand_factor
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def validate_unbounded_tries
|
|
81
|
+
if intervals
|
|
82
|
+
raise ArgumentError,
|
|
83
|
+
"intervals cannot be used with tries: Float::INFINITY"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
unless finite_number?(max_elapsed_time)
|
|
87
|
+
raise ArgumentError,
|
|
88
|
+
"max_elapsed_time must be a finite number when tries is Float::INFINITY"
|
|
47
89
|
end
|
|
90
|
+
|
|
91
|
+
validate_non_negative_number(:max_elapsed_time, max_elapsed_time)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate_intervals
|
|
95
|
+
return if intervals.nil?
|
|
96
|
+
raise ArgumentError, "intervals must be an Array" unless intervals.is_a?(Array)
|
|
97
|
+
return if intervals.all? { |interval| finite_number?(interval) && interval >= 0 }
|
|
98
|
+
|
|
99
|
+
raise ArgumentError, "intervals must contain only non-negative numbers"
|
|
48
100
|
end
|
|
49
101
|
end
|
|
50
102
|
end
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
require_relative "../../retriable"
|
|
4
4
|
|
|
5
5
|
module Kernel
|
|
6
|
-
def retriable(opts = {}, &
|
|
7
|
-
Retriable.retriable(opts, &
|
|
6
|
+
def retriable(opts = {}, &)
|
|
7
|
+
Retriable.retriable(opts, &)
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
def retriable_with_context(context_key, opts = {}, &
|
|
11
|
-
Retriable.with_context(context_key, opts, &
|
|
10
|
+
def retriable_with_context(context_key, opts = {}, &)
|
|
11
|
+
Retriable.with_context(context_key, opts, &)
|
|
12
12
|
end
|
|
13
13
|
end
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "validation"
|
|
4
|
+
|
|
3
5
|
module Retriable
|
|
4
6
|
class ExponentialBackoff
|
|
7
|
+
include Validation
|
|
8
|
+
|
|
5
9
|
ATTRIBUTES = %i[
|
|
6
10
|
tries
|
|
7
11
|
base_interval
|
|
@@ -24,20 +28,42 @@ module Retriable
|
|
|
24
28
|
|
|
25
29
|
instance_variable_set(:"@#{k}", v)
|
|
26
30
|
end
|
|
31
|
+
|
|
32
|
+
validate!
|
|
27
33
|
end
|
|
28
34
|
|
|
29
35
|
def intervals
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
provider = interval_provider
|
|
37
|
+
Array.new(tries) { |iteration| provider.call(iteration) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def interval_provider
|
|
41
|
+
raw_interval = base_interval
|
|
33
42
|
|
|
34
|
-
|
|
43
|
+
lambda do |_iteration|
|
|
44
|
+
interval = [raw_interval, max_interval].min
|
|
45
|
+
raw_interval = next_raw_interval(raw_interval)
|
|
35
46
|
|
|
36
|
-
|
|
47
|
+
rand_factor.zero? ? interval : randomize(interval)
|
|
48
|
+
end
|
|
37
49
|
end
|
|
38
50
|
|
|
39
51
|
private
|
|
40
52
|
|
|
53
|
+
def validate!
|
|
54
|
+
validate_non_negative_integer(:tries, tries)
|
|
55
|
+
validate_non_negative_number(:base_interval, base_interval)
|
|
56
|
+
validate_non_negative_number(:multiplier, multiplier)
|
|
57
|
+
validate_non_negative_number(:max_interval, max_interval)
|
|
58
|
+
validate_rand_factor
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def next_raw_interval(raw_interval)
|
|
62
|
+
return max_interval if multiplier >= 1 && raw_interval >= max_interval
|
|
63
|
+
|
|
64
|
+
raw_interval * multiplier
|
|
65
|
+
end
|
|
66
|
+
|
|
41
67
|
def randomize(interval)
|
|
42
68
|
delta = rand_factor * interval.to_f
|
|
43
69
|
min = interval - delta
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Retriable
|
|
4
|
+
module Validation
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def validate_positive_integer(name, value)
|
|
8
|
+
return if value.is_a?(Integer) && value.positive?
|
|
9
|
+
|
|
10
|
+
raise ArgumentError, "#{name} must be a positive integer"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def validate_non_negative_integer(name, value)
|
|
14
|
+
return if value.is_a?(Integer) && value >= 0
|
|
15
|
+
|
|
16
|
+
raise ArgumentError, "#{name} must be a non-negative integer"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def validate_non_negative_number(name, value)
|
|
20
|
+
return if finite_number?(value) && value >= 0
|
|
21
|
+
|
|
22
|
+
raise ArgumentError, "#{name} must be a non-negative number"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def validate_optional_non_negative_number(name, value)
|
|
26
|
+
return if value.nil?
|
|
27
|
+
|
|
28
|
+
validate_non_negative_number(name, value)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def validate_callable(name, value)
|
|
32
|
+
return unless value # nil/false disable the callback
|
|
33
|
+
return if value.respond_to?(:call)
|
|
34
|
+
|
|
35
|
+
raise ArgumentError, "#{name} must respond to #call or be nil"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def validate_rand_factor
|
|
39
|
+
return if finite_number?(rand_factor) && rand_factor >= 0 && rand_factor <= 1
|
|
40
|
+
|
|
41
|
+
raise ArgumentError, "rand_factor must be between 0 and 1"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def finite_number?(value)
|
|
45
|
+
value.is_a?(Numeric) && value.to_f.finite?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def unbounded_tries?(value)
|
|
49
|
+
value.is_a?(Numeric) && value.respond_to?(:infinite?) && value.infinite? == 1
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
module_function :unbounded_tries?
|
|
53
|
+
|
|
54
|
+
# Validates an `on:` value. Acceptable shapes:
|
|
55
|
+
# - a Class that descends from Exception
|
|
56
|
+
# - an Array or Set whose elements are Classes that descend from Exception
|
|
57
|
+
# - a Hash whose keys are such Classes and whose values are nil,
|
|
58
|
+
# a Regexp, or an Array of Regexps
|
|
59
|
+
#
|
|
60
|
+
# Without this validation, callers can pass values like `Object` or
|
|
61
|
+
# `Kernel` and silently retry process-critical exceptions such as
|
|
62
|
+
# SystemExit and Interrupt, because every Exception's ancestor chain
|
|
63
|
+
# includes both. Hash values that are not Regexps (e.g. plain Strings)
|
|
64
|
+
# also silently fail to match in #hash_exception_match?, so we require
|
|
65
|
+
# Regexp values explicitly.
|
|
66
|
+
def validate_on(value)
|
|
67
|
+
case value
|
|
68
|
+
in Hash
|
|
69
|
+
value.each do |klass, pattern|
|
|
70
|
+
validate_on_class(klass)
|
|
71
|
+
validate_on_hash_value(klass, pattern)
|
|
72
|
+
end
|
|
73
|
+
in Array | Set
|
|
74
|
+
value.each { |klass| validate_on_class(klass) }
|
|
75
|
+
else
|
|
76
|
+
validate_on_class(value)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def validate_on_class(klass)
|
|
81
|
+
return if klass.is_a?(Class) && klass <= Exception
|
|
82
|
+
|
|
83
|
+
raise ArgumentError, "on must be an Exception class or a collection of Exception classes, got #{klass.inspect}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def validate_on_hash_value(klass, pattern)
|
|
87
|
+
return if pattern.nil?
|
|
88
|
+
return if pattern.is_a?(Regexp)
|
|
89
|
+
return if pattern.is_a?(Array) && pattern.all?(Regexp)
|
|
90
|
+
|
|
91
|
+
raise ArgumentError,
|
|
92
|
+
"on[#{klass}] must be nil, a Regexp, or an Array of Regexps, got #{pattern.inspect}"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
data/lib/retriable/version.rb
CHANGED