retriable 3.6.0 → 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 +10 -0
- data/README.md +61 -3
- data/lib/retriable/config.rb +28 -4
- data/lib/retriable/exponential_backoff.rb +17 -5
- data/lib/retriable/validation.rb +50 -0
- data/lib/retriable/version.rb +1 -1
- data/lib/retriable.rb +42 -23
- data/spec/config_spec.rb +86 -0
- data/spec/exponential_backoff_spec.rb +45 -26
- data/spec/retriable_spec.rb +125 -11
- metadata +1 -1
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,15 @@
|
|
|
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
|
+
|
|
7
|
+
## 3.6.1
|
|
8
|
+
|
|
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.
|
|
10
|
+
- Fix: Validate `with_override(contexts:)` shape before applying overrides. `contexts` may be `nil` or a hash, and each per-context override must be a hash.
|
|
11
|
+
- Docs: Document that `on_retry: false` disables a callback set in `Retriable.configure` for a single call.
|
|
12
|
+
|
|
3
13
|
## 3.6.0
|
|
4
14
|
|
|
5
15
|
- Breaking: `Retriable.override` and `Retriable.reset_override` are removed and replaced by block-scoped `Retriable.with_override(opts) { ... }`. The new API requires a block, restores the previous override (or absence of override) when the block exits via `ensure`, and is thread-local — overrides set in one thread do not affect other threads, and child threads do not inherit them. Fibers within a thread still share the thread's active override. Nested `with_override` calls correctly restore the outer override on inner exit. See the README and `docs/testing.md` for migration and testing patterns. This replaces the override API introduced in 3.5.0.
|
data/README.md
CHANGED
|
@@ -5,6 +5,29 @@
|
|
|
5
5
|
|
|
6
6
|
Retriable is a simple DSL to retry failed code blocks with randomized [exponential backoff](http://en.wikipedia.org/wiki/Exponential_backoff) time intervals. This is especially useful when interacting external APIs, remote services, or file system calls.
|
|
7
7
|
|
|
8
|
+
## Table of Contents
|
|
9
|
+
|
|
10
|
+
- [Requirements](#requirements)
|
|
11
|
+
- [Installation](#installation)
|
|
12
|
+
- [Usage](#usage)
|
|
13
|
+
- [Defaults](#defaults)
|
|
14
|
+
- [Options](#options)
|
|
15
|
+
- [Configuring Which Options to Retry With :on](#configuring-which-options-to-retry-with-on)
|
|
16
|
+
- [Advanced Retry Matching With :retry_if](#advanced-retry-matching-with-retry_if)
|
|
17
|
+
- [Configuration](#configuration)
|
|
18
|
+
- [Override](#override)
|
|
19
|
+
- [Example Usage](#example-usage)
|
|
20
|
+
- [Custom Interval Array](#custom-interval-array)
|
|
21
|
+
- [Turn off Exponential Backoff](#turn-off-exponential-backoff)
|
|
22
|
+
- [Callbacks](#callbacks)
|
|
23
|
+
- [Ensure/Else](#ensureelse)
|
|
24
|
+
- [Contexts](#contexts)
|
|
25
|
+
- [Kernel Extension](#kernel-extension)
|
|
26
|
+
- [Testing](#testing)
|
|
27
|
+
- [Credits](#credits)
|
|
28
|
+
- [Development](#development)
|
|
29
|
+
- [Running Specs](#running-specs)
|
|
30
|
+
|
|
8
31
|
## Requirements
|
|
9
32
|
|
|
10
33
|
Ruby 2.3.0+
|
|
@@ -81,10 +104,10 @@ Here are the available options, in some vague order of relevance to most common
|
|
|
81
104
|
|
|
82
105
|
| Option | Default | Definition |
|
|
83
106
|
| ---------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
84
|
-
| **`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. |
|
|
85
108
|
| **`on`** | `[StandardError]` | Type of exceptions to retry. [Read more](#configuring-which-options-to-retry-with-on). |
|
|
86
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). |
|
|
87
|
-
| **`on_retry`** | `nil` | `Proc` to call after each try is rescued. [Read more](#callbacks).
|
|
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). |
|
|
88
111
|
| **`sleep_disabled`** | `false` | When true, disable exponential backoff and attempt retries immediately. |
|
|
89
112
|
| **`base_interval`** | `0.5` | The initial interval in seconds between tries. |
|
|
90
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`. |
|
|
@@ -94,7 +117,7 @@ Here are the available options, in some vague order of relevance to most common
|
|
|
94
117
|
| **`intervals`** | `nil` | Skip generated intervals and provide your own array of intervals in seconds. [Read more](#custom-interval-array). |
|
|
95
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. |
|
|
96
119
|
|
|
97
|
-
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`.
|
|
98
121
|
|
|
99
122
|
#### Configuring Which Options to Retry With :on
|
|
100
123
|
|
|
@@ -250,6 +273,21 @@ end
|
|
|
250
273
|
|
|
251
274
|
This example makes 5 total attempts. If the first attempt fails, the 2nd attempt occurs 0.5 seconds later.
|
|
252
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
|
+
|
|
253
291
|
### Turn off Exponential Backoff
|
|
254
292
|
|
|
255
293
|
Exponential backoff is enabled by default. If you want to simply retry code every second, 5 times maximum, you can do this:
|
|
@@ -294,6 +332,26 @@ Retriable.retriable(on_retry: do_this_on_each_retry) do
|
|
|
294
332
|
end
|
|
295
333
|
```
|
|
296
334
|
|
|
335
|
+
#### Disabling a Configured Callback Per Call
|
|
336
|
+
|
|
337
|
+
If `on_retry` is set in `Retriable.configure`, every call uses it by default. To opt a specific call out — for example, a critical call site that should not log on retry — pass `on_retry: false`. Passing `nil` does not work for this purpose because per-call options are merged over configured defaults; `false` is the explicit "disabled" sentinel.
|
|
338
|
+
|
|
339
|
+
```ruby
|
|
340
|
+
Retriable.configure do |c|
|
|
341
|
+
c.on_retry = ->(exception, try, elapsed_time, next_interval) { log(...) }
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Most calls use the configured callback.
|
|
345
|
+
Retriable.retriable do
|
|
346
|
+
# ...
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# This specific call opts out of the configured callback.
|
|
350
|
+
Retriable.retriable(on_retry: false) do
|
|
351
|
+
# ...
|
|
352
|
+
end
|
|
353
|
+
```
|
|
354
|
+
|
|
297
355
|
### Ensure/Else
|
|
298
356
|
|
|
299
357
|
What if I want to execute a code block at the end, whether or not an exception was rescued ([ensure](http://ruby-doc.org/docs/keywords/1.9/Object.html#method-i-ensure))? Or what if I want to execute a code block if no exception is raised ([else](http://ruby-doc.org/docs/keywords/1.9/Object.html#method-i-else))? Instead of providing more callbacks, I recommend you just wrap retriable in a begin/retry/else/ensure block:
|
data/lib/retriable/config.rb
CHANGED
|
@@ -53,19 +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)
|
|
57
|
+
validate_on(on)
|
|
58
58
|
validate_intervals
|
|
59
|
-
|
|
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
|
|
60
64
|
|
|
61
|
-
|
|
65
|
+
validate_positive_integer(:tries, tries)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
validate_backoff_options
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def validate_backoff_options
|
|
62
74
|
validate_non_negative_number(:base_interval, base_interval)
|
|
63
75
|
validate_non_negative_number(:multiplier, multiplier)
|
|
64
76
|
validate_non_negative_number(:max_interval, max_interval)
|
|
65
77
|
validate_rand_factor
|
|
66
78
|
end
|
|
67
79
|
|
|
68
|
-
|
|
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
|
|
69
93
|
|
|
70
94
|
def validate_intervals
|
|
71
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
|
@@ -37,5 +37,55 @@ module Retriable
|
|
|
37
37
|
def finite_number?(value)
|
|
38
38
|
value.is_a?(Numeric) && value.to_f.finite?
|
|
39
39
|
end
|
|
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
|
+
|
|
47
|
+
# Validates an `on:` value. Acceptable shapes:
|
|
48
|
+
# - a Class that descends from Exception
|
|
49
|
+
# - an Array whose elements are Classes that descend from Exception
|
|
50
|
+
# - a Hash whose keys are such Classes and whose values are nil,
|
|
51
|
+
# a Regexp, or an Array of Regexps
|
|
52
|
+
#
|
|
53
|
+
# Without this validation, callers can pass values like `Object` or
|
|
54
|
+
# `Kernel` and silently retry process-critical exceptions such as
|
|
55
|
+
# SystemExit and Interrupt, because every Exception's ancestor chain
|
|
56
|
+
# includes both. Hash values that are not Regexps (e.g. plain Strings)
|
|
57
|
+
# also silently fail to match in #hash_exception_match?, so we require
|
|
58
|
+
# Regexp values explicitly.
|
|
59
|
+
def validate_on(value)
|
|
60
|
+
case value
|
|
61
|
+
when Hash
|
|
62
|
+
value.each do |klass, pattern|
|
|
63
|
+
validate_on_class(klass)
|
|
64
|
+
validate_on_hash_value(klass, pattern)
|
|
65
|
+
end
|
|
66
|
+
when Array
|
|
67
|
+
value.each { |klass| validate_on_class(klass) }
|
|
68
|
+
else
|
|
69
|
+
validate_on_class(value)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def validate_on_class(klass)
|
|
74
|
+
return if klass.is_a?(Class) && klass <= Exception
|
|
75
|
+
|
|
76
|
+
raise ArgumentError, "on must be an Exception class or a collection of Exception classes, got #{klass.inspect}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def validate_on_hash_value(klass, pattern)
|
|
80
|
+
return if pattern.nil?
|
|
81
|
+
return if pattern.is_a?(Regexp)
|
|
82
|
+
# Ruby 2.3 does not support Enumerable#all?(pattern).
|
|
83
|
+
# rubocop:disable Style/PredicateWithKind
|
|
84
|
+
return if pattern.is_a?(Array) && pattern.all? { |p| p.is_a?(Regexp) }
|
|
85
|
+
# rubocop:enable Style/PredicateWithKind
|
|
86
|
+
|
|
87
|
+
raise ArgumentError,
|
|
88
|
+
"on[#{klass}] must be nil, a Regexp, or an Array of Regexps, got #{pattern.inspect}"
|
|
89
|
+
end
|
|
40
90
|
end
|
|
41
91
|
end
|
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)
|
|
114
122
|
|
|
123
|
+
RetryPlan.new(max_tries, ->(index) { index < max_tries - 1 ? provider.call(index) : nil })
|
|
124
|
+
end
|
|
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
|
|
@@ -166,16 +177,23 @@ module Retriable
|
|
|
166
177
|
raise ArgumentError, "#{k} is not a valid option" unless Config::ATTRIBUTES.include?(k)
|
|
167
178
|
end
|
|
168
179
|
|
|
180
|
+
return unless opts.key?(:contexts)
|
|
181
|
+
|
|
169
182
|
contexts = opts[:contexts]
|
|
170
|
-
return
|
|
183
|
+
return if contexts.nil?
|
|
171
184
|
|
|
172
|
-
contexts.
|
|
173
|
-
|
|
185
|
+
raise ArgumentError, "contexts must be a Hash or nil, got #{contexts.inspect}" unless contexts.is_a?(Hash)
|
|
186
|
+
|
|
187
|
+
contexts.each do |context_key, context_options|
|
|
188
|
+
validate_context_override_options(context_key, context_options)
|
|
174
189
|
end
|
|
175
190
|
end
|
|
176
191
|
|
|
177
|
-
def validate_context_override_options(context_options)
|
|
178
|
-
|
|
192
|
+
def validate_context_override_options(context_key, context_options)
|
|
193
|
+
unless context_options.is_a?(Hash)
|
|
194
|
+
raise ArgumentError,
|
|
195
|
+
"contexts[#{context_key.inspect}] must be a Hash, got #{context_options.inspect}"
|
|
196
|
+
end
|
|
179
197
|
|
|
180
198
|
context_attributes = Config::ATTRIBUTES - [:contexts]
|
|
181
199
|
context_options.each_key do |k|
|
|
@@ -224,7 +242,8 @@ module Retriable
|
|
|
224
242
|
:validate_override_options,
|
|
225
243
|
:validate_context_override_options,
|
|
226
244
|
:execute_tries,
|
|
227
|
-
:
|
|
245
|
+
:retry_plan,
|
|
246
|
+
:interval_provider,
|
|
228
247
|
:call_with_timeout,
|
|
229
248
|
:call_on_retry,
|
|
230
249
|
:can_retry?,
|
data/spec/config_spec.rb
CHANGED
|
@@ -65,4 +65,90 @@ describe Retriable::Config do
|
|
|
65
65
|
it "raises errors when intervals is not an array" do
|
|
66
66
|
expect { described_class.new(intervals: "1") }.to raise_error(ArgumentError, /intervals must be an Array/)
|
|
67
67
|
end
|
|
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
|
+
|
|
89
|
+
context "on: option validation" do
|
|
90
|
+
it "accepts a single Exception subclass" do
|
|
91
|
+
expect { described_class.new(on: StandardError) }.not_to raise_error
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it "accepts Exception itself" do
|
|
95
|
+
expect { described_class.new(on: Exception) }.not_to raise_error
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it "accepts an array of Exception subclasses" do
|
|
99
|
+
expect { described_class.new(on: [StandardError, RuntimeError]) }.not_to raise_error
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it "accepts a hash with nil pattern values" do
|
|
103
|
+
expect { described_class.new(on: { StandardError => nil }) }.not_to raise_error
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it "accepts a hash with Regexp pattern values" do
|
|
107
|
+
expect { described_class.new(on: { StandardError => /boom/ }) }.not_to raise_error
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it "accepts a hash with Array-of-Regexp pattern values" do
|
|
111
|
+
expect { described_class.new(on: { StandardError => [/a/, /b/] }) }.not_to raise_error
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it "rejects Object as on:" do
|
|
115
|
+
expect { described_class.new(on: Object) }
|
|
116
|
+
.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it "rejects Kernel as on:" do
|
|
120
|
+
expect { described_class.new(on: Kernel) }
|
|
121
|
+
.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "rejects an array containing a non-Exception class" do
|
|
125
|
+
expect { described_class.new(on: [StandardError, Kernel]) }
|
|
126
|
+
.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it "rejects a hash key that is not an Exception class" do
|
|
130
|
+
expect { described_class.new(on: { Kernel => nil }) }
|
|
131
|
+
.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it "rejects a hash value that is a String" do
|
|
135
|
+
expect { described_class.new(on: { StandardError => "boom" }) }
|
|
136
|
+
.to raise_error(ArgumentError, /on\[StandardError\] must be nil, a Regexp, or an Array of Regexps/)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it "rejects a hash value that is an Array containing a non-Regexp" do
|
|
140
|
+
expect { described_class.new(on: { StandardError => [/a/, "b"] }) }
|
|
141
|
+
.to raise_error(ArgumentError, /on\[StandardError\] must be nil, a Regexp, or an Array of Regexps/)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it "rejects a string passed as on:" do
|
|
145
|
+
expect { described_class.new(on: "StandardError") }
|
|
146
|
+
.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it "validates on: even when intervals is provided" do
|
|
150
|
+
expect { described_class.new(intervals: [0.1], on: Object) }
|
|
151
|
+
.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
68
154
|
end
|
|
@@ -22,17 +22,19 @@ describe Retriable::ExponentialBackoff do
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
it "generates 10 randomized intervals" do
|
|
25
|
-
expect(described_class.new(tries: 9).intervals).to eq(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
25
|
+
expect(described_class.new(tries: 9).intervals).to eq(
|
|
26
|
+
[
|
|
27
|
+
0.5244067512211441,
|
|
28
|
+
0.9113920238761231,
|
|
29
|
+
1.2406087918999114,
|
|
30
|
+
1.7632403621664823,
|
|
31
|
+
2.338001204738311,
|
|
32
|
+
4.350816718580626,
|
|
33
|
+
5.339852157217869,
|
|
34
|
+
11.889873261212443,
|
|
35
|
+
18.756037881636484,
|
|
36
|
+
],
|
|
37
|
+
)
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
it "generates defined number of intervals" do
|
|
@@ -40,19 +42,23 @@ describe Retriable::ExponentialBackoff do
|
|
|
40
42
|
end
|
|
41
43
|
|
|
42
44
|
it "generates intervals with a defined base interval" do
|
|
43
|
-
expect(described_class.new(base_interval: 1).intervals).to eq(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
expect(described_class.new(base_interval: 1).intervals).to eq(
|
|
46
|
+
[
|
|
47
|
+
1.0488135024422882,
|
|
48
|
+
1.8227840477522461,
|
|
49
|
+
2.4812175837998227,
|
|
50
|
+
],
|
|
51
|
+
)
|
|
48
52
|
end
|
|
49
53
|
|
|
50
54
|
it "generates intervals with a defined multiplier" do
|
|
51
|
-
expect(described_class.new(multiplier: 1).intervals).to eq(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
expect(described_class.new(multiplier: 1).intervals).to eq(
|
|
56
|
+
[
|
|
57
|
+
0.5244067512211441,
|
|
58
|
+
0.607594682584082,
|
|
59
|
+
0.5513816852888495,
|
|
60
|
+
],
|
|
61
|
+
)
|
|
56
62
|
end
|
|
57
63
|
|
|
58
64
|
it "generates intervals with a defined max interval" do
|
|
@@ -60,15 +66,28 @@ describe Retriable::ExponentialBackoff do
|
|
|
60
66
|
end
|
|
61
67
|
|
|
62
68
|
it "generates intervals with a defined rand_factor" do
|
|
63
|
-
expect(described_class.new(rand_factor: 0.2).intervals).to eq(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
69
|
+
expect(described_class.new(rand_factor: 0.2).intervals).to eq(
|
|
70
|
+
[
|
|
71
|
+
0.5097627004884576,
|
|
72
|
+
0.8145568095504492,
|
|
73
|
+
1.1712435167599646,
|
|
74
|
+
],
|
|
75
|
+
)
|
|
68
76
|
end
|
|
69
77
|
|
|
70
78
|
it "generates 10 non-randomized intervals" do
|
|
71
79
|
non_random_intervals = 9.times.inject([0.5]) { |memo, _i| memo + [memo.last * 1.5] }
|
|
72
80
|
expect(described_class.new(tries: 10, rand_factor: 0.0).intervals).to eq(non_random_intervals)
|
|
73
81
|
end
|
|
82
|
+
|
|
83
|
+
it "provides capped intervals lazily" do
|
|
84
|
+
interval_for = described_class.new(
|
|
85
|
+
base_interval: 1.0,
|
|
86
|
+
multiplier: 2.0,
|
|
87
|
+
max_interval: 4.0,
|
|
88
|
+
rand_factor: 0.0,
|
|
89
|
+
).interval_provider
|
|
90
|
+
|
|
91
|
+
expect(Array.new(5) { |index| interval_for.call(index) }).to eq([1.0, 2.0, 4.0, 4.0, 4.0])
|
|
92
|
+
end
|
|
74
93
|
end
|
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
|
|
@@ -406,6 +490,16 @@ describe Retriable do
|
|
|
406
490
|
|
|
407
491
|
expect(@tries).to eq(1)
|
|
408
492
|
end
|
|
493
|
+
|
|
494
|
+
it "rejects on: Object before invoking the block" do
|
|
495
|
+
block_invoked = false
|
|
496
|
+
|
|
497
|
+
expect do
|
|
498
|
+
described_class.retriable(on: Object) { block_invoked = true }
|
|
499
|
+
end.to raise_error(ArgumentError, /on must be an Exception class/)
|
|
500
|
+
|
|
501
|
+
expect(block_invoked).to be(false)
|
|
502
|
+
end
|
|
409
503
|
end
|
|
410
504
|
|
|
411
505
|
context "#configure" do
|
|
@@ -587,25 +681,45 @@ describe Retriable do
|
|
|
587
681
|
expect(@tries).to eq(1)
|
|
588
682
|
end
|
|
589
683
|
|
|
590
|
-
it "
|
|
591
|
-
|
|
592
|
-
c.contexts[:api] = { tries: 1 }
|
|
593
|
-
end
|
|
684
|
+
it "raises ArgumentError on non-hash override contexts values" do
|
|
685
|
+
block_called = false
|
|
594
686
|
|
|
595
|
-
described_class.with_override(contexts: 123)
|
|
596
|
-
|
|
687
|
+
expect { described_class.with_override(contexts: 123) { block_called = true } }
|
|
688
|
+
.to raise_error(ArgumentError, /contexts must be a Hash or nil/)
|
|
689
|
+
expect(block_called).to be(false)
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
it "raises ArgumentError on non-hash per-context override values" do
|
|
693
|
+
block_called = false
|
|
694
|
+
|
|
695
|
+
expect { described_class.with_override(contexts: { api: 123 }) { block_called = true } }
|
|
696
|
+
.to raise_error(ArgumentError, /contexts\[:api\] must be a Hash/)
|
|
697
|
+
expect(block_called).to be(false)
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
it "preserves outer override after rejected nested override contexts values" do
|
|
701
|
+
described_class.with_override(tries: 2) do
|
|
702
|
+
expect { described_class.with_override(tries: 1, contexts: 123) { :noop } }
|
|
703
|
+
.to raise_error(ArgumentError, /contexts must be a Hash or nil/)
|
|
704
|
+
|
|
705
|
+
expect { described_class.retriable(tries: 10) { increment_tries_with_exception } }
|
|
706
|
+
.to raise_error(StandardError)
|
|
597
707
|
end
|
|
598
708
|
|
|
599
|
-
expect(@tries).to eq(
|
|
709
|
+
expect(@tries).to eq(2)
|
|
600
710
|
end
|
|
601
711
|
|
|
602
|
-
it "
|
|
712
|
+
it "preserves outer context override after rejected nested per-context values" do
|
|
603
713
|
described_class.configure do |c|
|
|
604
|
-
c.contexts[:api] = { tries:
|
|
714
|
+
c.contexts[:api] = { tries: 10 }
|
|
605
715
|
end
|
|
606
716
|
|
|
607
|
-
described_class.with_override(contexts: { api:
|
|
608
|
-
expect { described_class.
|
|
717
|
+
described_class.with_override(contexts: { api: { tries: 2 } }) do
|
|
718
|
+
expect { described_class.with_override(contexts: { api: 123 }) { :noop } }
|
|
719
|
+
.to raise_error(ArgumentError, /contexts\[:api\] must be a Hash/)
|
|
720
|
+
|
|
721
|
+
expect { described_class.with_context(:api) { increment_tries_with_exception } }
|
|
722
|
+
.to raise_error(StandardError)
|
|
609
723
|
end
|
|
610
724
|
|
|
611
725
|
expect(@tries).to eq(2)
|