retriable 3.6.1 → 3.7.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 +4 -0
- data/README.md +17 -2
- data/lib/retriable/config.rb +27 -4
- data/lib/retriable/exponential_backoff.rb +17 -5
- data/lib/retriable/validation.rb +9 -0
- data/lib/retriable/version.rb +1 -1
- data/lib/retriable.rb +30 -18
- data/spec/config_spec.rb +20 -0
- data/spec/exponential_backoff_spec.rb +45 -26
- data/spec/retriable_spec.rb +84 -0
- metadata +1 -2
- data/docs/superpowers/specs/2026-05-26-on-give-up-callback-followups-design.md +0 -116
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a5f3739e08167965e6f60cc6104d78f46709c323c2b1a80a839afcd5049ba3ca
|
|
4
|
+
data.tar.gz: f9d5f2fe821d7629ceb6954c0afd1a99f63d8b6c99cecc9575e9b5672489be31
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f5d91b6ffed2805e0da09e307da9cddf018647c1ff89e040da96c9bffd26b5929465694d251c33fc19927288568b2ada4a52f3c1c50b384423038c0eb578634a
|
|
7
|
+
data.tar.gz: 1f1e5f0f352a34831f2e71eda177b88336a4d7571be1f4370d98a151e7d06b65468b7d05f02310087e322611149d751e8a271de2c6931bdc8efef9a5f1cb335f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# HEAD
|
|
2
2
|
|
|
3
|
+
## 3.7.0
|
|
4
|
+
|
|
5
|
+
- Feature: Opt-in unbounded retries via `tries: Float::INFINITY`. Requires a finite `max_elapsed_time` as a safety bound and is incompatible with custom `intervals:`. Both invalid configurations raise `ArgumentError` from `Config#validate!`.
|
|
6
|
+
|
|
3
7
|
## 3.6.1
|
|
4
8
|
|
|
5
9
|
- Fix: Validate the `on:` option before retrying. Previously, passing a non-`Exception` value such as `Object`, `Kernel`, or a plain `Module` (which appear in every `Exception`'s ancestor chain) would silently retry process-critical exceptions like `SystemExit` and `Interrupt`. The `on:` option now requires an `Exception` subclass, an array of them, or a hash whose keys are such classes and whose values are `nil`, a `Regexp`, or an array of `Regexp`s. Invalid shapes raise `ArgumentError` before the block runs.
|
data/README.md
CHANGED
|
@@ -104,7 +104,7 @@ Here are the available options, in some vague order of relevance to most common
|
|
|
104
104
|
|
|
105
105
|
| Option | Default | Definition |
|
|
106
106
|
| ---------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
107
|
-
| **`tries`** | `3` | Number of attempts to make at running your code block (includes initial attempt).
|
|
107
|
+
| **`tries`** | `3` | Number of attempts to make at running your code block (includes initial attempt). Pass `Float::INFINITY` to keep retrying until success or until `max_elapsed_time` is reached. |
|
|
108
108
|
| **`on`** | `[StandardError]` | Type of exceptions to retry. [Read more](#configuring-which-options-to-retry-with-on). |
|
|
109
109
|
| **`retry_if`** | `nil` | Callable (for example a `Proc` or lambda) that receives the rescued exception and returns true/false to decide whether to retry. [Read more](#advanced-retry-matching-with-retry_if). |
|
|
110
110
|
| **`on_retry`** | `nil` | `Proc` to call after each try is rescued. Pass `false` to disable a callback set in `#configure` for a single call. [Read more](#callbacks). |
|
|
@@ -117,7 +117,7 @@ Here are the available options, in some vague order of relevance to most common
|
|
|
117
117
|
| **`intervals`** | `nil` | Skip generated intervals and provide your own array of intervals in seconds. [Read more](#custom-interval-array). |
|
|
118
118
|
| **`timeout`** | `nil` | Number of seconds to allow the code block to run before raising a `Timeout::Error` inside each try. `nil` means the code block can run forever without raising error. The implementation uses `Timeout::timeout`, which may be [unsafe](https://jvns.ca/blog/2015/11/27/why-rubys-timeout-is-dangerous-and-thread-dot-raise-is-terrifying/) [and](http://blog.headius.com/2008/02/ruby-threadraise-threadkill-timeoutrb.html) [even](https://adamhooper.medium.com/in-ruby-dont-use-timeout-77d9d4e5a001) [dangerous](https://www.mikeperham.com/2015/05/08/timeout-rubys-most-dangerous-api/). Proceed with caution. |
|
|
119
119
|
|
|
120
|
-
Timing options are validated before retrying. `tries` must be a positive integer when Retriable generates intervals. `base_interval`, `max_interval`, `multiplier`, `max_elapsed_time`, and `timeout` must be non-negative numbers, with `max_elapsed_time` and `timeout` also accepting `nil`. `rand_factor` must be a number from `0` through `1`. If provided, `intervals` must be an array of non-negative numbers; because it replaces generated intervals, it also overrides `tries`, `base_interval`, `max_interval`, `rand_factor`, and `multiplier` validation.
|
|
120
|
+
Timing options are validated before retrying. `tries` must be a positive integer when Retriable generates intervals, or `Float::INFINITY` for unbounded retries. `base_interval`, `max_interval`, `multiplier`, `max_elapsed_time`, and `timeout` must be non-negative numbers, with `max_elapsed_time` and `timeout` also accepting `nil`. `rand_factor` must be a number from `0` through `1`. If provided, `intervals` must be an array of non-negative numbers; because it replaces generated intervals, it also overrides `tries`, `base_interval`, `max_interval`, `rand_factor`, and `multiplier` validation. `intervals` cannot be combined with `tries: Float::INFINITY`.
|
|
121
121
|
|
|
122
122
|
#### Configuring Which Options to Retry With :on
|
|
123
123
|
|
|
@@ -273,6 +273,21 @@ end
|
|
|
273
273
|
|
|
274
274
|
This example makes 5 total attempts. If the first attempt fails, the 2nd attempt occurs 0.5 seconds later.
|
|
275
275
|
|
|
276
|
+
### Unbounded Retries (Opt-in)
|
|
277
|
+
|
|
278
|
+
You can opt in to unbounded retries with `tries: Float::INFINITY`. This is useful for long-running worker processes where retrying should continue indefinitely, but it must be used with care.
|
|
279
|
+
|
|
280
|
+
```ruby
|
|
281
|
+
Retriable.retriable(tries: Float::INFINITY, max_elapsed_time: 300) do
|
|
282
|
+
# code here...
|
|
283
|
+
end
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
When `tries: Float::INFINITY` is set:
|
|
287
|
+
|
|
288
|
+
- `max_elapsed_time` must be a finite number. Retriable raises `ArgumentError` if it is `nil` or `Float::INFINITY`. This is a safety bound that prevents accidentally unbounded loops.
|
|
289
|
+
- Custom `intervals:` cannot be combined with `Float::INFINITY` and raises `ArgumentError`. Use the exponential backoff settings (`base_interval`, `multiplier`, `max_interval`, `rand_factor`) instead.
|
|
290
|
+
|
|
276
291
|
### Turn off Exponential Backoff
|
|
277
292
|
|
|
278
293
|
Exponential backoff is enabled by default. If you want to simply retry code every second, 5 times maximum, you can do this:
|
data/lib/retriable/config.rb
CHANGED
|
@@ -53,20 +53,43 @@ module Retriable
|
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
def validate!
|
|
56
|
-
validate_optional_non_negative_number(:max_elapsed_time, max_elapsed_time)
|
|
57
56
|
validate_optional_non_negative_number(:timeout, timeout)
|
|
58
57
|
validate_on(on)
|
|
59
58
|
validate_intervals
|
|
60
|
-
|
|
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
|
|
61
64
|
|
|
62
|
-
|
|
65
|
+
validate_positive_integer(:tries, tries)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
validate_backoff_options
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def validate_backoff_options
|
|
63
74
|
validate_non_negative_number(:base_interval, base_interval)
|
|
64
75
|
validate_non_negative_number(:multiplier, multiplier)
|
|
65
76
|
validate_non_negative_number(:max_interval, max_interval)
|
|
66
77
|
validate_rand_factor
|
|
67
78
|
end
|
|
68
79
|
|
|
69
|
-
|
|
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"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
validate_non_negative_number(:max_elapsed_time, max_elapsed_time)
|
|
92
|
+
end
|
|
70
93
|
|
|
71
94
|
def validate_intervals
|
|
72
95
|
return if intervals.nil?
|
|
@@ -33,13 +33,19 @@ module Retriable
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def intervals
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
provider = interval_provider
|
|
37
|
+
Array.new(tries) { |iteration| provider.call(iteration) }
|
|
38
|
+
end
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
def interval_provider
|
|
41
|
+
raw_interval = base_interval
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
lambda do |_iteration|
|
|
44
|
+
interval = [raw_interval, max_interval].min
|
|
45
|
+
raw_interval = next_raw_interval(raw_interval)
|
|
46
|
+
|
|
47
|
+
rand_factor.zero? ? interval : randomize(interval)
|
|
48
|
+
end
|
|
43
49
|
end
|
|
44
50
|
|
|
45
51
|
private
|
|
@@ -52,6 +58,12 @@ module Retriable
|
|
|
52
58
|
validate_rand_factor
|
|
53
59
|
end
|
|
54
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
|
+
|
|
55
67
|
def randomize(interval)
|
|
56
68
|
delta = rand_factor * interval.to_f
|
|
57
69
|
min = interval - delta
|
data/lib/retriable/validation.rb
CHANGED
|
@@ -38,6 +38,12 @@ module Retriable
|
|
|
38
38
|
value.is_a?(Numeric) && value.to_f.finite?
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
def unbounded_tries?(value)
|
|
42
|
+
value.is_a?(Numeric) && value.respond_to?(:infinite?) && value.infinite? == 1
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
module_function :unbounded_tries?
|
|
46
|
+
|
|
41
47
|
# Validates an `on:` value. Acceptable shapes:
|
|
42
48
|
# - a Class that descends from Exception
|
|
43
49
|
# - an Array whose elements are Classes that descend from Exception
|
|
@@ -73,7 +79,10 @@ module Retriable
|
|
|
73
79
|
def validate_on_hash_value(klass, pattern)
|
|
74
80
|
return if pattern.nil?
|
|
75
81
|
return if pattern.is_a?(Regexp)
|
|
82
|
+
# Ruby 2.3 does not support Enumerable#all?(pattern).
|
|
83
|
+
# rubocop:disable Style/PredicateWithKind
|
|
76
84
|
return if pattern.is_a?(Array) && pattern.all? { |p| p.is_a?(Regexp) }
|
|
85
|
+
# rubocop:enable Style/PredicateWithKind
|
|
77
86
|
|
|
78
87
|
raise ArgumentError,
|
|
79
88
|
"on[#{klass}] must be nil, a Regexp, or an Array of Regexps, got #{pattern.inspect}"
|
data/lib/retriable/version.rb
CHANGED
data/lib/retriable.rb
CHANGED
|
@@ -13,6 +13,9 @@ module Retriable
|
|
|
13
13
|
# break callers that use fiber-based concurrency.
|
|
14
14
|
OVERRIDE_THREAD_KEY = :retriable_override
|
|
15
15
|
|
|
16
|
+
RetryPlan = Struct.new(:max_tries, :interval_for)
|
|
17
|
+
private_constant :RetryPlan
|
|
18
|
+
|
|
16
19
|
module_function
|
|
17
20
|
|
|
18
21
|
def configure
|
|
@@ -62,8 +65,7 @@ module Retriable
|
|
|
62
65
|
# Config is mutable through `configure`, so validate again immediately before use.
|
|
63
66
|
local_config.validate!
|
|
64
67
|
|
|
65
|
-
|
|
66
|
-
intervals = build_intervals(local_config, tries)
|
|
68
|
+
plan = retry_plan(local_config)
|
|
67
69
|
timeout = local_config.timeout
|
|
68
70
|
on = local_config.on
|
|
69
71
|
retry_if = local_config.retry_if
|
|
@@ -76,10 +78,8 @@ module Retriable
|
|
|
76
78
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
77
79
|
elapsed_time = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time }
|
|
78
80
|
|
|
79
|
-
tries = intervals.size + 1
|
|
80
|
-
|
|
81
81
|
execute_tries(
|
|
82
|
-
|
|
82
|
+
max_tries: plan.max_tries, interval_for: plan.interval_for, timeout: timeout,
|
|
83
83
|
exception_list: exception_list, on: on, retry_if: retry_if, on_retry: on_retry,
|
|
84
84
|
elapsed_time: elapsed_time, max_elapsed_time: max_elapsed_time,
|
|
85
85
|
sleep_disabled: sleep_disabled, &block
|
|
@@ -87,38 +87,49 @@ module Retriable
|
|
|
87
87
|
end
|
|
88
88
|
|
|
89
89
|
def execute_tries( # rubocop:disable Metrics/ParameterLists
|
|
90
|
-
|
|
90
|
+
max_tries:, interval_for:, timeout:, exception_list:,
|
|
91
91
|
on:, retry_if:, on_retry:, elapsed_time:, max_elapsed_time:, sleep_disabled:, &block
|
|
92
92
|
)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
try = 0
|
|
94
|
+
loop do
|
|
95
|
+
try += 1
|
|
96
96
|
begin
|
|
97
97
|
return call_with_timeout(timeout, try, &block)
|
|
98
98
|
rescue *exception_list => e
|
|
99
99
|
raise unless retriable_exception?(e, on, exception_list, retry_if)
|
|
100
100
|
|
|
101
|
-
interval =
|
|
101
|
+
interval = interval_for.call(try - 1)
|
|
102
102
|
call_on_retry(on_retry, e, try, elapsed_time.call, interval)
|
|
103
103
|
|
|
104
104
|
elapsed_interval = sleep_disabled == true ? 0 : interval
|
|
105
|
-
raise unless can_retry?(try,
|
|
105
|
+
raise unless can_retry?(try, max_tries, elapsed_time.call, elapsed_interval, max_elapsed_time)
|
|
106
106
|
|
|
107
107
|
sleep interval if sleep_disabled != true
|
|
108
108
|
end
|
|
109
109
|
end
|
|
110
110
|
end
|
|
111
111
|
|
|
112
|
-
def
|
|
113
|
-
return
|
|
112
|
+
def retry_plan(local_config)
|
|
113
|
+
return RetryPlan.new(nil, interval_provider(local_config)) if Validation.unbounded_tries?(local_config.tries)
|
|
114
|
+
|
|
115
|
+
if local_config.intervals
|
|
116
|
+
intervals = local_config.intervals
|
|
117
|
+
return RetryPlan.new(intervals.size + 1, ->(index) { intervals[index] })
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
max_tries = local_config.tries
|
|
121
|
+
provider = interval_provider(local_config)
|
|
122
|
+
|
|
123
|
+
RetryPlan.new(max_tries, ->(index) { index < max_tries - 1 ? provider.call(index) : nil })
|
|
124
|
+
end
|
|
114
125
|
|
|
126
|
+
def interval_provider(local_config)
|
|
115
127
|
ExponentialBackoff.new(
|
|
116
|
-
tries: tries - 1,
|
|
117
128
|
base_interval: local_config.base_interval,
|
|
118
129
|
multiplier: local_config.multiplier,
|
|
119
130
|
max_interval: local_config.max_interval,
|
|
120
131
|
rand_factor: local_config.rand_factor,
|
|
121
|
-
).
|
|
132
|
+
).interval_provider
|
|
122
133
|
end
|
|
123
134
|
|
|
124
135
|
def call_with_timeout(timeout, try)
|
|
@@ -133,8 +144,8 @@ module Retriable
|
|
|
133
144
|
on_retry.call(exception, try, elapsed_time, interval)
|
|
134
145
|
end
|
|
135
146
|
|
|
136
|
-
def can_retry?(try,
|
|
137
|
-
return false
|
|
147
|
+
def can_retry?(try, max_tries, elapsed_time, interval, max_elapsed_time)
|
|
148
|
+
return false if max_tries && try >= max_tries
|
|
138
149
|
return true if max_elapsed_time.nil?
|
|
139
150
|
|
|
140
151
|
(elapsed_time + interval) <= max_elapsed_time
|
|
@@ -231,7 +242,8 @@ module Retriable
|
|
|
231
242
|
:validate_override_options,
|
|
232
243
|
:validate_context_override_options,
|
|
233
244
|
:execute_tries,
|
|
234
|
-
:
|
|
245
|
+
:retry_plan,
|
|
246
|
+
:interval_provider,
|
|
235
247
|
:call_with_timeout,
|
|
236
248
|
:call_on_retry,
|
|
237
249
|
:can_retry?,
|
data/spec/config_spec.rb
CHANGED
|
@@ -66,6 +66,26 @@ describe Retriable::Config do
|
|
|
66
66
|
expect { described_class.new(intervals: "1") }.to raise_error(ArgumentError, /intervals must be an Array/)
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
+
it "requires a finite max_elapsed_time when tries is Float::INFINITY" do
|
|
70
|
+
expect { described_class.new(tries: Float::INFINITY, max_elapsed_time: nil) }
|
|
71
|
+
.to raise_error(ArgumentError, /max_elapsed_time must be a finite number/)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it "rejects intervals combined with tries: Float::INFINITY" do
|
|
75
|
+
expect do
|
|
76
|
+
described_class.new(
|
|
77
|
+
tries: Float::INFINITY,
|
|
78
|
+
max_elapsed_time: 60,
|
|
79
|
+
intervals: [0.1, 0.2],
|
|
80
|
+
)
|
|
81
|
+
end.to raise_error(ArgumentError, /intervals cannot be used with tries: Float::INFINITY/)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it "accepts tries: Float::INFINITY with a finite max_elapsed_time" do
|
|
85
|
+
expect { described_class.new(tries: Float::INFINITY, max_elapsed_time: 60) }
|
|
86
|
+
.not_to raise_error
|
|
87
|
+
end
|
|
88
|
+
|
|
69
89
|
context "on: option validation" do
|
|
70
90
|
it "accepts a single Exception subclass" do
|
|
71
91
|
expect { described_class.new(on: StandardError) }.not_to raise_error
|
|
@@ -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
|
data/spec/retriable_spec.rb
CHANGED
|
@@ -84,6 +84,90 @@ describe Retriable do
|
|
|
84
84
|
expect(@tries).to eq(10)
|
|
85
85
|
end
|
|
86
86
|
|
|
87
|
+
it "does not prebuild generated intervals before the first successful try" do
|
|
88
|
+
interval_for = ->(_index) { raise "interval should not be used" }
|
|
89
|
+
backoff = instance_double(Retriable::ExponentialBackoff, interval_provider: interval_for)
|
|
90
|
+
allow(Retriable::ExponentialBackoff).to receive(:new).and_call_original
|
|
91
|
+
allow(Retriable::ExponentialBackoff).to receive(:new).with(
|
|
92
|
+
hash_including(:base_interval, :multiplier, :max_interval, :rand_factor),
|
|
93
|
+
).and_return(backoff)
|
|
94
|
+
|
|
95
|
+
described_class.retriable(tries: 1_000_000) { increment_tries }
|
|
96
|
+
|
|
97
|
+
expect(@tries).to eq(1)
|
|
98
|
+
expect(backoff).to have_received(:interval_provider)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it "supports unbounded retries until the block succeeds" do
|
|
102
|
+
described_class.retriable(tries: Float::INFINITY, max_elapsed_time: 60) do
|
|
103
|
+
increment_tries
|
|
104
|
+
raise StandardError if @tries < 5
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
expect(@tries).to eq(5)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it "stops unbounded retries at max_elapsed_time" do
|
|
111
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
112
|
+
timeline = [
|
|
113
|
+
start_time,
|
|
114
|
+
start_time,
|
|
115
|
+
start_time,
|
|
116
|
+
start_time + 0.01,
|
|
117
|
+
start_time + 0.01,
|
|
118
|
+
start_time + 0.02,
|
|
119
|
+
start_time + 0.02,
|
|
120
|
+
]
|
|
121
|
+
allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC) { timeline.shift || timeline.last }
|
|
122
|
+
|
|
123
|
+
expect do
|
|
124
|
+
described_class.retriable(
|
|
125
|
+
tries: Float::INFINITY,
|
|
126
|
+
base_interval: 0.01,
|
|
127
|
+
multiplier: 1.0,
|
|
128
|
+
rand_factor: 0.0,
|
|
129
|
+
sleep_disabled: true,
|
|
130
|
+
max_elapsed_time: 0.015,
|
|
131
|
+
) do
|
|
132
|
+
increment_tries_with_exception
|
|
133
|
+
end
|
|
134
|
+
end.to raise_error(StandardError)
|
|
135
|
+
|
|
136
|
+
expect(@tries).to eq(3)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it "raises ArgumentError when tries is Float::INFINITY without a finite max_elapsed_time" do
|
|
140
|
+
expect do
|
|
141
|
+
described_class.retriable(tries: Float::INFINITY, max_elapsed_time: nil) { increment_tries }
|
|
142
|
+
end.to raise_error(ArgumentError, /max_elapsed_time must be a finite number/)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it "raises ArgumentError when tries is Float::INFINITY with infinite max_elapsed_time" do
|
|
146
|
+
expect do
|
|
147
|
+
described_class.retriable(tries: Float::INFINITY, max_elapsed_time: Float::INFINITY) { increment_tries }
|
|
148
|
+
end.to raise_error(ArgumentError, /max_elapsed_time must be a finite number/)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
it "raises ArgumentError when tries is Float::INFINITY with custom intervals" do
|
|
152
|
+
expect do
|
|
153
|
+
described_class.retriable(tries: Float::INFINITY, intervals: [0.1, 0.2], max_elapsed_time: 60) do
|
|
154
|
+
increment_tries_with_exception
|
|
155
|
+
end
|
|
156
|
+
end.to raise_error(ArgumentError, /intervals cannot be used with tries: Float::INFINITY/)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it "raises ArgumentError when tries is Float::NAN" do
|
|
160
|
+
expect do
|
|
161
|
+
described_class.retriable(tries: Float::NAN) { increment_tries }
|
|
162
|
+
end.to raise_error(ArgumentError, /tries/)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it "raises ArgumentError when tries is negative infinity" do
|
|
166
|
+
expect do
|
|
167
|
+
described_class.retriable(tries: -Float::INFINITY) { increment_tries }
|
|
168
|
+
end.to raise_error(ArgumentError, /tries/)
|
|
169
|
+
end
|
|
170
|
+
|
|
87
171
|
it "will timeout after 1 second" do
|
|
88
172
|
expect { described_class.retriable(timeout: 1) { sleep(1.1) } }.to raise_error(Timeout::Error)
|
|
89
173
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: retriable
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jack Chu
|
|
@@ -74,7 +74,6 @@ files:
|
|
|
74
74
|
- Rakefile
|
|
75
75
|
- bin/console
|
|
76
76
|
- bin/setup
|
|
77
|
-
- docs/superpowers/specs/2026-05-26-on-give-up-callback-followups-design.md
|
|
78
77
|
- docs/testing.md
|
|
79
78
|
- lib/retriable.rb
|
|
80
79
|
- lib/retriable/config.rb
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
# Design: `on_give_up` callback follow-ups (issue #72, PR #127)
|
|
2
|
-
|
|
3
|
-
## Context
|
|
4
|
-
|
|
5
|
-
- Issue [#72](https://github.com/kamui/retriable/issues/72) requests a callback that fires only after all retries are exhausted.
|
|
6
|
-
- Draft PR [#127](https://github.com/kamui/retriable/pull/127) (branch `feat/on-give-up-callback`, authored by maintainer @kamui) implements this as `on_give_up`. It is largely production-ready.
|
|
7
|
-
|
|
8
|
-
This spec defines a small set of follow-up additions on top of `feat/on-give-up-callback`. It does **not** revisit the design decisions already settled in #127 (naming, signature, reason symbols, opt-out behavior).
|
|
9
|
-
|
|
10
|
-
## Settled decisions inherited from #127
|
|
11
|
-
|
|
12
|
-
| Decision | Resolution |
|
|
13
|
-
| --- | --- |
|
|
14
|
-
| Callback name | `on_give_up` |
|
|
15
|
-
| Signature | `(exception, try, elapsed_time, next_interval, reason)` |
|
|
16
|
-
| Reason values | `:tries_exhausted`, `:max_elapsed_time` |
|
|
17
|
-
| `next_interval` when `:tries_exhausted` | `nil` |
|
|
18
|
-
| `next_interval` when `:max_elapsed_time` | The interval that would have been slept before the next try |
|
|
19
|
-
| Opt-out | `on_give_up: false` (or `nil`) disables a configured handler |
|
|
20
|
-
| Order vs `on_retry` | `on_retry` runs first; `on_give_up` runs just before re-raise |
|
|
21
|
-
| Non-retriable exception types | `on_give_up` does **not** fire |
|
|
22
|
-
| `retry_if` rejection | `on_give_up` does **not** fire |
|
|
23
|
-
| `elapsed_time` for the give-up decision | Re-read after `on_retry` returns so handler time counts toward `max_elapsed_time` |
|
|
24
|
-
| Threading through `Config::ATTRIBUTES` | Already enables `with_context`, `override`, and `configure` automatically |
|
|
25
|
-
|
|
26
|
-
## Gaps to fill
|
|
27
|
-
|
|
28
|
-
PR #127 has the mechanics right. The follow-up work below closes documentation and test-coverage gaps and locks in undocumented-but-implied semantics.
|
|
29
|
-
|
|
30
|
-
### 1. Document non-firing cases in README
|
|
31
|
-
|
|
32
|
-
PR #127 covers the firing cases. The README should explicitly state when the callback does **not** fire, because users wiring up paging/metrics need to know.
|
|
33
|
-
|
|
34
|
-
Add one paragraph at the end of the new `on_give_up` subsection in `README.md`:
|
|
35
|
-
|
|
36
|
-
> `on_give_up` is invoked only when Retriable rescued an exception that matched the retry rules and then decided to stop. It does **not** fire when the block raises an exception that is not in `on`, nor when `retry_if` returns false. Both of those cases are immediate re-raises, not retry exhaustion, and should be handled with normal Ruby `rescue` blocks around the `Retriable.retriable` call.
|
|
37
|
-
|
|
38
|
-
### 2. Document handler-raised-error policy in README
|
|
39
|
-
|
|
40
|
-
Current `on_retry` documentation does not state what happens if the handler itself raises. PR #127 silently inherits the same behavior: an exception inside `on_give_up` propagates, replacing the original. Make this explicit.
|
|
41
|
-
|
|
42
|
-
Add one sentence to the same subsection:
|
|
43
|
-
|
|
44
|
-
> If `on_give_up` itself raises, that exception propagates to the caller and replaces the original retried exception. Keep the handler defensive (rescue inside it) if you need the original exception to surface.
|
|
45
|
-
|
|
46
|
-
### 3. Mention `on_give_up` in the Contexts example
|
|
47
|
-
|
|
48
|
-
`README.md` already has a Contexts example at `README.md:306`. Extend the `:aws` context to demonstrate `on_give_up`:
|
|
49
|
-
|
|
50
|
-
```ruby
|
|
51
|
-
Retriable.configure do |c|
|
|
52
|
-
c.contexts[:aws] = {
|
|
53
|
-
tries: 3,
|
|
54
|
-
base_interval: 5,
|
|
55
|
-
on_retry: Proc.new { puts 'Curse you, AWS!' },
|
|
56
|
-
on_give_up: Proc.new { |_e, _try, _elapsed, _interval, reason|
|
|
57
|
-
puts "Gave up on AWS: #{reason}"
|
|
58
|
-
},
|
|
59
|
-
}
|
|
60
|
-
end
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
### 4. Test: per-context `override` accepts and dispatches `on_give_up`
|
|
64
|
-
|
|
65
|
-
PR #127 adds a positive `with_context` spec and an `override` spec for top-level overrides, but no spec for the call shape `Retriable.override(contexts: { key: { on_give_up: ... } })`, which is validated by `validate_context_override_options` and applied by `context_options_for`. Add one spec under the existing `#override` context that:
|
|
66
|
-
|
|
67
|
-
1. Calls `Retriable.override(contexts: { api: { on_give_up: handler, tries: 1 } })`.
|
|
68
|
-
2. Invokes `Retriable.with_context(:api) { raise StandardError }`.
|
|
69
|
-
3. Asserts the handler was invoked exactly once with `reason == :tries_exhausted`.
|
|
70
|
-
|
|
71
|
-
### 5. Test: kernel extension passes `on_give_up` through
|
|
72
|
-
|
|
73
|
-
PR #127 does not exercise the kernel extension (`Kernel#retriable` and `Kernel#retriable_with_context`). The delegation is trivial, but a regression guard is cheap. Add one spec inside the existing `context "global scope extension"` block that requires `retriable/core_ext/kernel`, invokes `retriable(tries: 1, on_give_up: handler) { raise }`, and asserts the handler ran with `reason == :tries_exhausted`. A second `retriable_with_context` spec is not needed because item 4 already covers the context-dispatch path.
|
|
74
|
-
|
|
75
|
-
### 6. Test: handler that raises propagates and replaces the original
|
|
76
|
-
|
|
77
|
-
Lock in the policy from item 2 with a spec: handler raises `RuntimeError`, caller observes `RuntimeError`, not the original `StandardError`.
|
|
78
|
-
|
|
79
|
-
### 7. CHANGELOG entry: include signature and reasons
|
|
80
|
-
|
|
81
|
-
PR #127's CHANGELOG line is:
|
|
82
|
-
|
|
83
|
-
> - Add `on_give_up` callback to observe when retries stop because tries are exhausted or the next retry would exceed `max_elapsed_time`.
|
|
84
|
-
|
|
85
|
-
Rewrite to:
|
|
86
|
-
|
|
87
|
-
> - Add `on_give_up` callback that runs when Retriable stops retrying after a rescued retriable exception. Receives `(exception, try, elapsed_time, next_interval, reason)`, where `reason` is `:tries_exhausted` or `:max_elapsed_time`. Does not fire for non-retriable exceptions or `retry_if` rejections. Pass `on_give_up: false` to suppress a configured handler for a single call.
|
|
88
|
-
|
|
89
|
-
## Out of scope
|
|
90
|
-
|
|
91
|
-
- Renaming `on_give_up`. The maintainer authored the draft with this name.
|
|
92
|
-
- Changing the callback signature (e.g., removing `next_interval`).
|
|
93
|
-
- Firing for `retry_if` rejection. That decision was made deliberately in #127.
|
|
94
|
-
- Version bump. Deferred to the maintainer's release commit.
|
|
95
|
-
- Touching the pre-existing rubocop offenses noted in PR #127's description (`retriable.gemspec`, `spec/exponential_backoff_spec.rb`).
|
|
96
|
-
|
|
97
|
-
## Files touched
|
|
98
|
-
|
|
99
|
-
- `README.md` — items 1, 2, 3.
|
|
100
|
-
- `spec/retriable_spec.rb` — items 4, 5, 6.
|
|
101
|
-
- `CHANGELOG.md` — item 7.
|
|
102
|
-
|
|
103
|
-
No changes to `lib/retriable.rb` or `lib/retriable/config.rb`; PR #127's implementation already satisfies the behavior.
|
|
104
|
-
|
|
105
|
-
## Verification
|
|
106
|
-
|
|
107
|
-
```sh
|
|
108
|
-
bundle exec rspec
|
|
109
|
-
bundle exec rubocop lib spec
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
Both must pass. Pre-existing rubocop offenses in `retriable.gemspec` and `spec/exponential_backoff_spec.rb` are intentionally left untouched (see Out of scope).
|
|
113
|
-
|
|
114
|
-
## Delivery
|
|
115
|
-
|
|
116
|
-
Push as additional commits on the existing `feat/on-give-up-callback` branch (PR #127). If we lack push access to the maintainer's branch, open a PR targeting `feat/on-give-up-callback` with these follow-ups, or post the diff as a review comment on #127.
|