retriable 3.6.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/CHANGELOG.md +10 -0
- data/README.md +51 -23
- data/lib/retriable/config.rb +78 -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 +113 -1
- data/spec/exponential_backoff_spec.rb +45 -26
- data/spec/retriable_spec.rb +167 -2
- data/spec/spec_helper.rb +13 -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: 25948e6545b8d98b0c7b08416bdf23b017ced352eb2a4eb1f2e9fdac45912031
|
|
4
|
+
data.tar.gz: 4a92afb17f79a4ad173b046158ca634b0728fc6ccceedc1b7789c9106342b513
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e43731b305a64c806bf57c13d78da410412d8ea4adaec9f255bd434b2d190ac53b6fac29172b72ed6bcc7329a3d5378c4075d72f6dede791f1e0bd16f400e58e
|
|
7
|
+
data.tar.gz: 4259468487738b9ae5bcc817ac992af3341f15bcfe604ab0a61ddf1f8793b444775aa57c06ce5f95561e179186e7360e86a877afd02f3b346b68377ffb3ecaf9
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# HEAD
|
|
2
2
|
|
|
3
|
+
## 3.8.0
|
|
4
|
+
|
|
5
|
+
### Deprecations
|
|
6
|
+
|
|
7
|
+
- Deprecated the `timeout:` option ahead of its removal in Retriable 4.0. Non-nil timeout values supplied through `Retriable.configure`, `Retriable.retriable(...)`, or `Retriable.with_override(...)` now emit a deprecation warning while keeping the existing runtime behavior unchanged. On Ruby 2.7+ the warning is emitted via `Kernel.warn(..., category: :deprecated)`, so callers can silence it through the standard Ruby controls (`Warning[:deprecated] = false`, `ruby -W:no-deprecated`, or a custom `Warning.warn`). To keep the notice from drowning busy applications, it is emitted at most once per process; suppression via `Warning[:deprecated]` leaves the warner armed for the next call that re-enables the category. Prefer library-native timeout settings, or wrap the retried block in `Timeout.timeout(...)` directly if you still need that behavior. See the README migration guidance for details.
|
|
8
|
+
|
|
9
|
+
## 3.7.0
|
|
10
|
+
|
|
11
|
+
- 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!`.
|
|
12
|
+
|
|
3
13
|
## 3.6.1
|
|
4
14
|
|
|
5
15
|
- 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
|
@@ -17,6 +17,7 @@ Retriable is a simple DSL to retry failed code blocks with randomized [exponenti
|
|
|
17
17
|
- [Configuration](#configuration)
|
|
18
18
|
- [Override](#override)
|
|
19
19
|
- [Example Usage](#example-usage)
|
|
20
|
+
- [Migrating off `timeout:`](#migrating-off-timeout)
|
|
20
21
|
- [Custom Interval Array](#custom-interval-array)
|
|
21
22
|
- [Turn off Exponential Backoff](#turn-off-exponential-backoff)
|
|
22
23
|
- [Callbacks](#callbacks)
|
|
@@ -55,7 +56,7 @@ require 'retriable'
|
|
|
55
56
|
In your Gemfile:
|
|
56
57
|
|
|
57
58
|
```ruby
|
|
58
|
-
gem 'retriable', '~> 3.
|
|
59
|
+
gem 'retriable', '~> 3.8'
|
|
59
60
|
```
|
|
60
61
|
|
|
61
62
|
## Usage
|
|
@@ -102,22 +103,22 @@ The default interval table with 10 tries looks like this (in seconds, rounded to
|
|
|
102
103
|
|
|
103
104
|
Here are the available options, in some vague order of relevance to most common use patterns:
|
|
104
105
|
|
|
105
|
-
| Option | Default | Definition
|
|
106
|
-
| ---------------------- | ----------------- |
|
|
107
|
-
| **`tries`** | `3` | Number of attempts to make at running your code block (includes initial attempt).
|
|
108
|
-
| **`on`** | `[StandardError]` | Type of exceptions to retry. [Read more](#configuring-which-options-to-retry-with-on).
|
|
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
|
-
| **`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).
|
|
111
|
-
| **`sleep_disabled`** | `false` | When true, disable exponential backoff and attempt retries immediately.
|
|
112
|
-
| **`base_interval`** | `0.5` | The initial interval in seconds between tries.
|
|
113
|
-
| **`max_elapsed_time`** | `900` (15 min) | The maximum amount of total time in seconds that code is allowed to keep being retried. Set to `nil` to disable the time limit and retry based solely on `tries`.
|
|
114
|
-
| **`max_interval`** | `60` | The maximum interval in seconds that any individual retry can reach.
|
|
115
|
-
| **`multiplier`** | `1.5` | Each successive interval grows by this factor. A multipler of 1.5 means the next interval will be 1.5x the current interval.
|
|
116
|
-
| **`rand_factor`** | `0.5` | The percentage to randomize the next retry interval time. The next interval calculation is `randomized_interval = retry_interval * (random value in range [1 - randomization_factor, 1 + randomization_factor])`
|
|
117
|
-
| **`intervals`** | `nil` | Skip generated intervals and provide your own array of intervals in seconds. [Read more](#custom-interval-array).
|
|
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.
|
|
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.
|
|
106
|
+
| Option | Default | Definition |
|
|
107
|
+
| ---------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
108
|
+
| **`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. |
|
|
109
|
+
| **`on`** | `[StandardError]` | Type of exceptions to retry. [Read more](#configuring-which-options-to-retry-with-on). |
|
|
110
|
+
| **`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). |
|
|
111
|
+
| **`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). |
|
|
112
|
+
| **`sleep_disabled`** | `false` | When true, disable exponential backoff and attempt retries immediately. |
|
|
113
|
+
| **`base_interval`** | `0.5` | The initial interval in seconds between tries. |
|
|
114
|
+
| **`max_elapsed_time`** | `900` (15 min) | The maximum amount of total time in seconds that code is allowed to keep being retried. Set to `nil` to disable the time limit and retry based solely on `tries`. |
|
|
115
|
+
| **`max_interval`** | `60` | The maximum interval in seconds that any individual retry can reach. |
|
|
116
|
+
| **`multiplier`** | `1.5` | Each successive interval grows by this factor. A multipler of 1.5 means the next interval will be 1.5x the current interval. |
|
|
117
|
+
| **`rand_factor`** | `0.5` | The percentage to randomize the next retry interval time. The next interval calculation is `randomized_interval = retry_interval * (random value in range [1 - randomization_factor, 1 + randomization_factor])` |
|
|
118
|
+
| **`intervals`** | `nil` | Skip generated intervals and provide your own array of intervals in seconds. [Read more](#custom-interval-array). |
|
|
119
|
+
| **`timeout`** | `nil` | Deprecated in 3.8.0 and removed in 4.0. 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. Non-nil values emit a deprecation warning. See [Migrating off `timeout:`](#migrating-off-timeout). |
|
|
120
|
+
|
|
121
|
+
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
122
|
|
|
122
123
|
#### Configuring Which Options to Retry With :on
|
|
123
124
|
|
|
@@ -243,20 +244,32 @@ Retriable.retriable(on: {
|
|
|
243
244
|
end
|
|
244
245
|
```
|
|
245
246
|
|
|
246
|
-
|
|
247
|
+
#### Migrating off `timeout:`
|
|
247
248
|
|
|
248
|
-
The
|
|
249
|
+
The `timeout:` option is deprecated in Retriable 3.8.0 and will be removed in Retriable 4.0. It still works in 3.x, but any non-nil value supplied through `Retriable.configure`, `Retriable.retriable(...)`, or `Retriable.with_override(...)` emits a deprecation warning. In Retriable 4.0, passing `timeout:` will raise `ArgumentError` because it will no longer be a valid option.
|
|
250
|
+
|
|
251
|
+
`timeout:` is deprecated because it is a thin wrapper around `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/). It can interrupt the retried block at any line, including inside libraries that are not interrupt-safe.
|
|
252
|
+
|
|
253
|
+
Prefer timeout settings from the library you are calling, such as `Net::HTTP#read_timeout`, `Net::HTTP#open_timeout`, or Faraday's request timeout options. If you still need `Timeout.timeout`, wrap the retried block explicitly so the risk is visible at the call site:
|
|
249
254
|
|
|
250
255
|
```ruby
|
|
251
|
-
|
|
252
|
-
|
|
256
|
+
require "timeout"
|
|
257
|
+
|
|
258
|
+
Retriable.retriable(on: Timeout::Error, tries: 3) do
|
|
259
|
+
Timeout.timeout(5) do
|
|
260
|
+
# code here...
|
|
261
|
+
end
|
|
253
262
|
end
|
|
254
263
|
```
|
|
255
264
|
|
|
256
|
-
If you
|
|
265
|
+
Like the deprecated `timeout:` option, `Timeout.timeout(5)` inside the block is per-try — each retry gets a fresh 5-second budget. If you want an overall cap across all retries instead, prefer `max_elapsed_time:`.
|
|
266
|
+
|
|
267
|
+
The deprecation warning is emitted under the `:deprecated` warning category and at most once per process. To silence it (for example, in tests), use the standard Ruby controls — set `Warning[:deprecated] = false`, run with `ruby -W:no-deprecated`, or override `Warning.warn` to filter the message.
|
|
268
|
+
|
|
269
|
+
If you need millisecond units of time for the sleep interval:
|
|
257
270
|
|
|
258
271
|
```ruby
|
|
259
|
-
Retriable.retriable(base_interval: (200 / 1000.0)
|
|
272
|
+
Retriable.retriable(base_interval: (200 / 1000.0)) do
|
|
260
273
|
# code here...
|
|
261
274
|
end
|
|
262
275
|
```
|
|
@@ -273,6 +286,21 @@ end
|
|
|
273
286
|
|
|
274
287
|
This example makes 5 total attempts. If the first attempt fails, the 2nd attempt occurs 0.5 seconds later.
|
|
275
288
|
|
|
289
|
+
### Unbounded Retries (Opt-in)
|
|
290
|
+
|
|
291
|
+
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.
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
Retriable.retriable(tries: Float::INFINITY, max_elapsed_time: 300) do
|
|
295
|
+
# code here...
|
|
296
|
+
end
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
When `tries: Float::INFINITY` is set:
|
|
300
|
+
|
|
301
|
+
- `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.
|
|
302
|
+
- Custom `intervals:` cannot be combined with `Float::INFINITY` and raises `ArgumentError`. Use the exponential backoff settings (`base_interval`, `multiplier`, `max_interval`, `rand_factor`) instead.
|
|
303
|
+
|
|
276
304
|
### Turn off Exponential Backoff
|
|
277
305
|
|
|
278
306
|
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
|
@@ -18,6 +18,19 @@ module Retriable
|
|
|
18
18
|
contexts
|
|
19
19
|
]).freeze
|
|
20
20
|
|
|
21
|
+
TIMEOUT_DEPRECATION_MESSAGE = "NOTE: Retriable's `timeout:` option is deprecated and will be removed in " \
|
|
22
|
+
"Retriable 4.0. It is a thin wrapper around `Timeout.timeout`, which " \
|
|
23
|
+
"can interrupt execution at arbitrary lines and corrupt internal state " \
|
|
24
|
+
"in libraries that are not interrupt-safe. Prefer your library's native " \
|
|
25
|
+
"timeout, or wrap your block in `Timeout.timeout(...)` yourself."
|
|
26
|
+
private_constant :TIMEOUT_DEPRECATION_MESSAGE
|
|
27
|
+
|
|
28
|
+
@timeout_deprecation_warned = false
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
attr_accessor :timeout_deprecation_warned
|
|
32
|
+
end
|
|
33
|
+
|
|
21
34
|
attr_accessor(*ATTRIBUTES)
|
|
22
35
|
|
|
23
36
|
def initialize(opts = {})
|
|
@@ -53,20 +66,81 @@ module Retriable
|
|
|
53
66
|
end
|
|
54
67
|
|
|
55
68
|
def validate!
|
|
56
|
-
|
|
69
|
+
warn_timeout_deprecation
|
|
57
70
|
validate_optional_non_negative_number(:timeout, timeout)
|
|
58
71
|
validate_on(on)
|
|
59
72
|
validate_intervals
|
|
60
|
-
|
|
73
|
+
if unbounded_tries?(tries)
|
|
74
|
+
validate_unbounded_tries
|
|
75
|
+
else
|
|
76
|
+
validate_optional_non_negative_number(:max_elapsed_time, max_elapsed_time)
|
|
77
|
+
return if intervals
|
|
78
|
+
|
|
79
|
+
validate_positive_integer(:tries, tries)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
validate_backoff_options
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# Emits the `timeout:` deprecation notice at most once per process.
|
|
88
|
+
#
|
|
89
|
+
# On Rubies that support `Kernel#warn(category: :deprecated)` (2.7+), the
|
|
90
|
+
# notice is emitted under the `:deprecated` category, so callers can use the
|
|
91
|
+
# standard controls (`Warning[:deprecated] = false`, `-W:no-deprecated`,
|
|
92
|
+
# `Warning.warn` override) to silence it. On older Rubies the kwarg is not
|
|
93
|
+
# available and we fall back to plain `Kernel.warn`.
|
|
94
|
+
#
|
|
95
|
+
# When the warning is suppressed (either because `Warning[:deprecated]` is
|
|
96
|
+
# false or the runtime has otherwise muted the category), we deliberately
|
|
97
|
+
# leave the once-per-process flag unset so a future call with the category
|
|
98
|
+
# re-enabled still surfaces the notice.
|
|
99
|
+
def warn_timeout_deprecation
|
|
100
|
+
return if timeout.nil?
|
|
101
|
+
return if self.class.timeout_deprecation_warned
|
|
102
|
+
|
|
103
|
+
category_supported = deprecated_warning_category_supported?
|
|
104
|
+
return if category_supported && !deprecated_warnings_enabled?
|
|
105
|
+
|
|
106
|
+
self.class.timeout_deprecation_warned = true
|
|
107
|
+
if category_supported
|
|
108
|
+
Kernel.warn(TIMEOUT_DEPRECATION_MESSAGE, category: :deprecated)
|
|
109
|
+
else
|
|
110
|
+
Kernel.warn(TIMEOUT_DEPRECATION_MESSAGE)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def deprecated_warning_category_supported?
|
|
115
|
+
defined?(Warning) && Kernel.method(:warn).parameters.any? { |type, name| type == :key && name == :category }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def deprecated_warnings_enabled?
|
|
119
|
+
return true unless defined?(Warning) && Warning.respond_to?(:[])
|
|
120
|
+
|
|
121
|
+
Warning[:deprecated]
|
|
122
|
+
end
|
|
61
123
|
|
|
62
|
-
|
|
124
|
+
def validate_backoff_options
|
|
63
125
|
validate_non_negative_number(:base_interval, base_interval)
|
|
64
126
|
validate_non_negative_number(:multiplier, multiplier)
|
|
65
127
|
validate_non_negative_number(:max_interval, max_interval)
|
|
66
128
|
validate_rand_factor
|
|
67
129
|
end
|
|
68
130
|
|
|
69
|
-
|
|
131
|
+
def validate_unbounded_tries
|
|
132
|
+
if intervals
|
|
133
|
+
raise ArgumentError,
|
|
134
|
+
"intervals cannot be used with tries: Float::INFINITY"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
unless finite_number?(max_elapsed_time)
|
|
138
|
+
raise ArgumentError,
|
|
139
|
+
"max_elapsed_time must be a finite number when tries is Float::INFINITY"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
validate_non_negative_number(:max_elapsed_time, max_elapsed_time)
|
|
143
|
+
end
|
|
70
144
|
|
|
71
145
|
def validate_intervals
|
|
72
146
|
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
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
3
5
|
describe Retriable::Config do
|
|
4
6
|
let(:default_config) { described_class.new }
|
|
5
7
|
|
|
@@ -59,13 +61,123 @@ describe Retriable::Config do
|
|
|
59
61
|
|
|
60
62
|
it "raises errors on invalid timing configuration" do
|
|
61
63
|
expect { described_class.new(rand_factor: 1.1) }.to raise_error(ArgumentError, /rand_factor/)
|
|
62
|
-
expect
|
|
64
|
+
expect do
|
|
65
|
+
expect { described_class.new(timeout: -1) }.to raise_error(ArgumentError, /timeout/)
|
|
66
|
+
end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
context "timeout deprecation" do
|
|
70
|
+
it "warns when timeout is configured" do
|
|
71
|
+
expect do
|
|
72
|
+
described_class.new(timeout: 5)
|
|
73
|
+
end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "warns when timeout is set before validation" do
|
|
77
|
+
config = described_class.new
|
|
78
|
+
config.timeout = 5
|
|
79
|
+
|
|
80
|
+
expect do
|
|
81
|
+
config.validate!
|
|
82
|
+
end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "does not warn when timeout is nil" do
|
|
86
|
+
expect do
|
|
87
|
+
described_class.new(timeout: nil)
|
|
88
|
+
end.not_to output.to_stderr
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it "does not warn when timeout is omitted" do
|
|
92
|
+
expect do
|
|
93
|
+
described_class.new
|
|
94
|
+
end.not_to output.to_stderr
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "warns at most once per process" do
|
|
98
|
+
original_stderr = $stderr
|
|
99
|
+
stderr = StringIO.new
|
|
100
|
+
begin
|
|
101
|
+
$stderr = stderr
|
|
102
|
+
|
|
103
|
+
described_class.new(timeout: 5)
|
|
104
|
+
described_class.new(timeout: 5)
|
|
105
|
+
|
|
106
|
+
config = described_class.new
|
|
107
|
+
config.timeout = 5
|
|
108
|
+
config.validate!
|
|
109
|
+
ensure
|
|
110
|
+
$stderr = original_stderr
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
expect(stderr.string.scan("timeout:` option is deprecated").size).to eq(1)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it "emits the warning under the :deprecated category when supported", if: WARN_CATEGORY_SUPPORTED do
|
|
117
|
+
captured = []
|
|
118
|
+
allow(Warning).to receive(:warn) do |message, category: nil|
|
|
119
|
+
captured << [message, category]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
described_class.new(timeout: 5)
|
|
123
|
+
|
|
124
|
+
expect(captured.size).to eq(1)
|
|
125
|
+
message, category = captured.first
|
|
126
|
+
expect(message).to match(/timeout.*deprecated.*Retriable 4\.0/i)
|
|
127
|
+
expect(category).to eq(:deprecated)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it "is silenced by Warning[:deprecated] = false", if: WARN_CATEGORY_SUPPORTED do
|
|
131
|
+
original = Warning[:deprecated]
|
|
132
|
+
begin
|
|
133
|
+
Warning[:deprecated] = false
|
|
134
|
+
expect do
|
|
135
|
+
described_class.new(timeout: 5)
|
|
136
|
+
end.not_to output.to_stderr
|
|
137
|
+
ensure
|
|
138
|
+
Warning[:deprecated] = original
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it "remains armed when silenced via Warning[:deprecated]", if: WARN_CATEGORY_SUPPORTED do
|
|
143
|
+
original = Warning[:deprecated]
|
|
144
|
+
begin
|
|
145
|
+
Warning[:deprecated] = false
|
|
146
|
+
described_class.new(timeout: 5)
|
|
147
|
+
ensure
|
|
148
|
+
Warning[:deprecated] = original
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
expect do
|
|
152
|
+
described_class.new(timeout: 5)
|
|
153
|
+
end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
|
|
154
|
+
end
|
|
63
155
|
end
|
|
64
156
|
|
|
65
157
|
it "raises errors when intervals is not an array" do
|
|
66
158
|
expect { described_class.new(intervals: "1") }.to raise_error(ArgumentError, /intervals must be an Array/)
|
|
67
159
|
end
|
|
68
160
|
|
|
161
|
+
it "requires a finite max_elapsed_time when tries is Float::INFINITY" do
|
|
162
|
+
expect { described_class.new(tries: Float::INFINITY, max_elapsed_time: nil) }
|
|
163
|
+
.to raise_error(ArgumentError, /max_elapsed_time must be a finite number/)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it "rejects intervals combined with tries: Float::INFINITY" do
|
|
167
|
+
expect do
|
|
168
|
+
described_class.new(
|
|
169
|
+
tries: Float::INFINITY,
|
|
170
|
+
max_elapsed_time: 60,
|
|
171
|
+
intervals: [0.1, 0.2],
|
|
172
|
+
)
|
|
173
|
+
end.to raise_error(ArgumentError, /intervals cannot be used with tries: Float::INFINITY/)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it "accepts tries: Float::INFINITY with a finite max_elapsed_time" do
|
|
177
|
+
expect { described_class.new(tries: Float::INFINITY, max_elapsed_time: 60) }
|
|
178
|
+
.not_to raise_error
|
|
179
|
+
end
|
|
180
|
+
|
|
69
181
|
context "on: option validation" do
|
|
70
182
|
it "accepts a single Exception subclass" do
|
|
71
183
|
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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "rbconfig"
|
|
4
|
+
require "stringio"
|
|
4
5
|
|
|
5
6
|
describe Retriable do
|
|
6
7
|
let(:time_table_handler) do
|
|
@@ -50,7 +51,9 @@ describe Retriable do
|
|
|
50
51
|
|
|
51
52
|
it "raises a LocalJumpError if not given a block" do
|
|
52
53
|
expect { described_class.retriable }.to raise_error(LocalJumpError)
|
|
53
|
-
expect
|
|
54
|
+
expect do
|
|
55
|
+
expect { described_class.retriable(timeout: 2) }.to raise_error(LocalJumpError)
|
|
56
|
+
end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
|
|
54
57
|
end
|
|
55
58
|
|
|
56
59
|
it "stops at first try if the block does not raise an exception" do
|
|
@@ -84,8 +87,170 @@ describe Retriable do
|
|
|
84
87
|
expect(@tries).to eq(10)
|
|
85
88
|
end
|
|
86
89
|
|
|
90
|
+
it "does not prebuild generated intervals before the first successful try" do
|
|
91
|
+
interval_for = ->(_index) { raise "interval should not be used" }
|
|
92
|
+
backoff = instance_double(Retriable::ExponentialBackoff, interval_provider: interval_for)
|
|
93
|
+
allow(Retriable::ExponentialBackoff).to receive(:new).and_call_original
|
|
94
|
+
allow(Retriable::ExponentialBackoff).to receive(:new).with(
|
|
95
|
+
hash_including(:base_interval, :multiplier, :max_interval, :rand_factor),
|
|
96
|
+
).and_return(backoff)
|
|
97
|
+
|
|
98
|
+
described_class.retriable(tries: 1_000_000) { increment_tries }
|
|
99
|
+
|
|
100
|
+
expect(@tries).to eq(1)
|
|
101
|
+
expect(backoff).to have_received(:interval_provider)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it "supports unbounded retries until the block succeeds" do
|
|
105
|
+
described_class.retriable(tries: Float::INFINITY, max_elapsed_time: 60) do
|
|
106
|
+
increment_tries
|
|
107
|
+
raise StandardError if @tries < 5
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
expect(@tries).to eq(5)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it "stops unbounded retries at max_elapsed_time" do
|
|
114
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
115
|
+
timeline = [
|
|
116
|
+
start_time,
|
|
117
|
+
start_time,
|
|
118
|
+
start_time,
|
|
119
|
+
start_time + 0.01,
|
|
120
|
+
start_time + 0.01,
|
|
121
|
+
start_time + 0.02,
|
|
122
|
+
start_time + 0.02,
|
|
123
|
+
]
|
|
124
|
+
allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC) { timeline.shift || timeline.last }
|
|
125
|
+
|
|
126
|
+
expect do
|
|
127
|
+
described_class.retriable(
|
|
128
|
+
tries: Float::INFINITY,
|
|
129
|
+
base_interval: 0.01,
|
|
130
|
+
multiplier: 1.0,
|
|
131
|
+
rand_factor: 0.0,
|
|
132
|
+
sleep_disabled: true,
|
|
133
|
+
max_elapsed_time: 0.015,
|
|
134
|
+
) do
|
|
135
|
+
increment_tries_with_exception
|
|
136
|
+
end
|
|
137
|
+
end.to raise_error(StandardError)
|
|
138
|
+
|
|
139
|
+
expect(@tries).to eq(3)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it "raises ArgumentError when tries is Float::INFINITY without a finite max_elapsed_time" do
|
|
143
|
+
expect do
|
|
144
|
+
described_class.retriable(tries: Float::INFINITY, max_elapsed_time: nil) { increment_tries }
|
|
145
|
+
end.to raise_error(ArgumentError, /max_elapsed_time must be a finite number/)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it "raises ArgumentError when tries is Float::INFINITY with infinite max_elapsed_time" do
|
|
149
|
+
expect do
|
|
150
|
+
described_class.retriable(tries: Float::INFINITY, max_elapsed_time: Float::INFINITY) { increment_tries }
|
|
151
|
+
end.to raise_error(ArgumentError, /max_elapsed_time must be a finite number/)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it "raises ArgumentError when tries is Float::INFINITY with custom intervals" do
|
|
155
|
+
expect do
|
|
156
|
+
described_class.retriable(tries: Float::INFINITY, intervals: [0.1, 0.2], max_elapsed_time: 60) do
|
|
157
|
+
increment_tries_with_exception
|
|
158
|
+
end
|
|
159
|
+
end.to raise_error(ArgumentError, /intervals cannot be used with tries: Float::INFINITY/)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it "raises ArgumentError when tries is Float::NAN" do
|
|
163
|
+
expect do
|
|
164
|
+
described_class.retriable(tries: Float::NAN) { increment_tries }
|
|
165
|
+
end.to raise_error(ArgumentError, /tries/)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it "raises ArgumentError when tries is negative infinity" do
|
|
169
|
+
expect do
|
|
170
|
+
described_class.retriable(tries: -Float::INFINITY) { increment_tries }
|
|
171
|
+
end.to raise_error(ArgumentError, /tries/)
|
|
172
|
+
end
|
|
173
|
+
|
|
87
174
|
it "will timeout after 1 second" do
|
|
88
|
-
expect
|
|
175
|
+
expect do
|
|
176
|
+
expect { described_class.retriable(timeout: 1) { sleep(1.1) } }.to raise_error(Timeout::Error)
|
|
177
|
+
end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
context "timeout: deprecation" do
|
|
181
|
+
it "warns at most once per process across repeated retriable calls" do
|
|
182
|
+
original_stderr = $stderr
|
|
183
|
+
stderr = StringIO.new
|
|
184
|
+
begin
|
|
185
|
+
$stderr = stderr
|
|
186
|
+
|
|
187
|
+
described_class.retriable(timeout: 5) { :noop }
|
|
188
|
+
described_class.retriable(timeout: 5) { :noop }
|
|
189
|
+
described_class.retriable(timeout: 5) { :noop }
|
|
190
|
+
|
|
191
|
+
expect(stderr.string.scan("timeout:` option is deprecated").size).to eq(1)
|
|
192
|
+
ensure
|
|
193
|
+
$stderr = original_stderr
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
it "warns when timeout is passed to retriable" do
|
|
198
|
+
expect do
|
|
199
|
+
described_class.retriable(timeout: 5) { :noop }
|
|
200
|
+
end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
it "keeps applying timeout while deprecated" do
|
|
204
|
+
original_stderr = $stderr
|
|
205
|
+
begin
|
|
206
|
+
$stderr = StringIO.new
|
|
207
|
+
expect do
|
|
208
|
+
described_class.retriable(timeout: 0.05, tries: 1) { sleep(0.5) }
|
|
209
|
+
end.to raise_error(Timeout::Error)
|
|
210
|
+
ensure
|
|
211
|
+
$stderr = original_stderr
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
it "warns when timeout is supplied through with_override" do
|
|
216
|
+
expect do
|
|
217
|
+
described_class.with_override(timeout: 5) do
|
|
218
|
+
described_class.retriable { :noop }
|
|
219
|
+
end
|
|
220
|
+
end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
it "warns when timeout is supplied through configure" do
|
|
224
|
+
original_config = described_class.config
|
|
225
|
+
begin
|
|
226
|
+
expect do
|
|
227
|
+
described_class.configure { |config| config.timeout = 5 }
|
|
228
|
+
described_class.retriable { :noop }
|
|
229
|
+
end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
|
|
230
|
+
ensure
|
|
231
|
+
described_class.configure do |config|
|
|
232
|
+
original_config.to_h.each { |key, value| config.public_send("#{key}=", value) }
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
it "is silenced by Warning[:deprecated] = false", if: WARN_CATEGORY_SUPPORTED do
|
|
238
|
+
original = Warning[:deprecated]
|
|
239
|
+
begin
|
|
240
|
+
Warning[:deprecated] = false
|
|
241
|
+
expect do
|
|
242
|
+
described_class.retriable(timeout: 5) { :noop }
|
|
243
|
+
end.not_to output.to_stderr
|
|
244
|
+
ensure
|
|
245
|
+
Warning[:deprecated] = original
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
it "does not warn when timeout is absent" do
|
|
250
|
+
expect do
|
|
251
|
+
described_class.retriable { :noop }
|
|
252
|
+
end.not_to output.to_stderr
|
|
253
|
+
end
|
|
89
254
|
end
|
|
90
255
|
|
|
91
256
|
it "applies a randomized exponential backoff to each try" do
|
data/spec/spec_helper.rb
CHANGED
|
@@ -7,8 +7,21 @@ require "pry"
|
|
|
7
7
|
require_relative "../lib/retriable"
|
|
8
8
|
require_relative "support/exceptions"
|
|
9
9
|
|
|
10
|
+
# Make Retriable's deprecation notices observable to RSpec's
|
|
11
|
+
# `output().to_stderr` matcher. On Ruby 3.0+ the `:deprecated` warning category
|
|
12
|
+
# is suppressed by default, which would hide the notices we want to assert on.
|
|
13
|
+
WARNING_DEPRECATION_SUPPORTED = defined?(Warning) && Warning.respond_to?(:[])
|
|
14
|
+
Warning[:deprecated] = true if WARNING_DEPRECATION_SUPPORTED
|
|
15
|
+
|
|
16
|
+
# Used by deprecation specs that only make sense on Rubies where `Kernel#warn`
|
|
17
|
+
# supports the `category:` keyword (added in Ruby 2.7).
|
|
18
|
+
WARN_CATEGORY_SUPPORTED = WARNING_DEPRECATION_SUPPORTED &&
|
|
19
|
+
Kernel.method(:warn).parameters.include?(%i[key category])
|
|
20
|
+
|
|
10
21
|
RSpec.configure do |config|
|
|
11
22
|
config.before(:each) do
|
|
12
23
|
srand(0)
|
|
24
|
+
Retriable::Config.timeout_deprecation_warned = false
|
|
25
|
+
Warning[:deprecated] = true if WARNING_DEPRECATION_SUPPORTED
|
|
13
26
|
end
|
|
14
27
|
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.8.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.
|