retriable 3.3.0 → 3.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +29 -2
- data/lib/retriable/config.rb +10 -8
- data/lib/retriable/exponential_backoff.rb +2 -2
- data/lib/retriable/version.rb +1 -1
- data/lib/retriable.rb +16 -9
- data/spec/config_spec.rb +4 -0
- data/spec/retriable_spec.rb +76 -0
- 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: 86086d1d868b9f609e96d2b5e8e5f6e5910a9822b7c21dd86a4014a12105d7f3
|
|
4
|
+
data.tar.gz: a7324a228f6b0565428cb10afe6ecf3a98b7a6984ced4f83c32595f97767b09b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fc0f71e125a40e52fdb8e4cb99b71b066fc84a067e9011b166a14d08a9f98f20a15a32402818705172275ea8b0a0f814963265eabba9fcae6d7ac40cdfc9e520
|
|
7
|
+
data.tar.gz: d13f82d53fdc1c2be693a056fd4d164cddc4a7b34096c6c7367213f899881687854a3df5a20a07ad3678c3aa257297bd42a68cb36e9cf0718a2fa8be54a3bd56
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# HEAD
|
|
2
2
|
|
|
3
|
+
## 3.4.1
|
|
4
|
+
|
|
5
|
+
- Fix: Use `Process.clock_gettime(CLOCK_MONOTONIC)` for elapsed time tracking so retry timing is immune to wall-clock adjustments (NTP, manual changes).
|
|
6
|
+
- Fix: Handle `max_elapsed_time: nil` gracefully instead of raising `NoMethodError`.
|
|
7
|
+
- Remove dead `* 1.0` float coercion in `ExponentialBackoff#randomize`.
|
|
8
|
+
|
|
9
|
+
## 3.4.0
|
|
10
|
+
|
|
11
|
+
- Add `retry_if` option to support custom retry predicates, including checks against wrapped `exception.cause` values.
|
|
12
|
+
|
|
3
13
|
## 3.3.0
|
|
4
14
|
|
|
5
15
|
- Refactor `Retriable.retriable` internals into focused private helpers to improve readability while preserving behavior.
|
data/README.md
CHANGED
|
@@ -32,7 +32,7 @@ require 'retriable'
|
|
|
32
32
|
In your Gemfile:
|
|
33
33
|
|
|
34
34
|
```ruby
|
|
35
|
-
gem 'retriable', '~> 3.
|
|
35
|
+
gem 'retriable', '~> 3.4'
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
## Usage
|
|
@@ -83,10 +83,11 @@ Here are the available options, in some vague order of relevance to most common
|
|
|
83
83
|
| ---------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
84
84
|
| **`tries`** | `3` | Number of attempts to make at running your code block (includes initial attempt). |
|
|
85
85
|
| **`on`** | `[StandardError]` | Type of exceptions to retry. [Read more](#configuring-which-options-to-retry-with-on). |
|
|
86
|
+
| **`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). |
|
|
86
87
|
| **`on_retry`** | `nil` | `Proc` to call after each try is rescued. [Read more](#callbacks). |
|
|
87
88
|
| **`sleep_disabled`** | `false` | When true, disable exponential backoff and attempt retries immediately. |
|
|
88
89
|
| **`base_interval`** | `0.5` | The initial interval in seconds between tries. |
|
|
89
|
-
| **`max_elapsed_time`** | `900` (15 min) | The maximum amount of total time in seconds that code is allowed to keep being retried.
|
|
90
|
+
| **`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`. |
|
|
90
91
|
| **`max_interval`** | `60` | The maximum interval in seconds that any individual retry can reach. |
|
|
91
92
|
| **`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. |
|
|
92
93
|
| **`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])` |
|
|
@@ -104,6 +105,32 @@ Here are the available options, in some vague order of relevance to most common
|
|
|
104
105
|
- A single `Regexp` pattern (retries exceptions ONLY if their `message` matches the pattern)
|
|
105
106
|
- An array of patterns (retries exceptions ONLY if their `message` matches at least one of the patterns)
|
|
106
107
|
|
|
108
|
+
#### Advanced Retry Matching With :retry_if
|
|
109
|
+
|
|
110
|
+
Use **`:retry_if`** when retry logic depends on details that `:on` does not cover. The Proc receives the rescued exception and should return `true` to retry or `false` to re-raise immediately.
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
def caused_by?(error, klass)
|
|
114
|
+
current = error
|
|
115
|
+
while current
|
|
116
|
+
return true if current.is_a?(klass)
|
|
117
|
+
|
|
118
|
+
current = current.cause
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
false
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
Retriable.retriable(
|
|
125
|
+
on: [Faraday::ConnectionFailed],
|
|
126
|
+
retry_if: ->(exception) { caused_by?(exception, Errno::ECONNRESET) }
|
|
127
|
+
) do
|
|
128
|
+
# code here...
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`:retry_if` runs after the exception type has matched `:on`.
|
|
133
|
+
|
|
107
134
|
### Configuration
|
|
108
135
|
|
|
109
136
|
You can change the global defaults with a `#configure` block:
|
data/lib/retriable/config.rb
CHANGED
|
@@ -4,14 +4,15 @@ require_relative "exponential_backoff"
|
|
|
4
4
|
|
|
5
5
|
module Retriable
|
|
6
6
|
class Config
|
|
7
|
-
ATTRIBUTES = (ExponentialBackoff::ATTRIBUTES + [
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
7
|
+
ATTRIBUTES = (ExponentialBackoff::ATTRIBUTES + %i[
|
|
8
|
+
sleep_disabled
|
|
9
|
+
max_elapsed_time
|
|
10
|
+
intervals
|
|
11
|
+
timeout
|
|
12
|
+
on
|
|
13
|
+
retry_if
|
|
14
|
+
on_retry
|
|
15
|
+
contexts
|
|
15
16
|
]).freeze
|
|
16
17
|
|
|
17
18
|
attr_accessor(*ATTRIBUTES)
|
|
@@ -29,6 +30,7 @@ module Retriable
|
|
|
29
30
|
@intervals = nil
|
|
30
31
|
@timeout = nil
|
|
31
32
|
@on = [StandardError]
|
|
33
|
+
@retry_if = nil
|
|
32
34
|
@on_retry = nil
|
|
33
35
|
@contexts = {}
|
|
34
36
|
|
|
@@ -28,7 +28,7 @@ module Retriable
|
|
|
28
28
|
|
|
29
29
|
def intervals
|
|
30
30
|
intervals = Array.new(tries) do |iteration|
|
|
31
|
-
[base_interval * multiplier**iteration, max_interval].min
|
|
31
|
+
[base_interval * (multiplier**iteration), max_interval].min
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
return intervals if rand_factor.zero?
|
|
@@ -39,7 +39,7 @@ module Retriable
|
|
|
39
39
|
private
|
|
40
40
|
|
|
41
41
|
def randomize(interval)
|
|
42
|
-
delta = rand_factor * interval
|
|
42
|
+
delta = rand_factor * interval.to_f
|
|
43
43
|
min = interval - delta
|
|
44
44
|
max = interval + delta
|
|
45
45
|
rand(min..max)
|
data/lib/retriable/version.rb
CHANGED
data/lib/retriable.rb
CHANGED
|
@@ -18,7 +18,8 @@ module Retriable
|
|
|
18
18
|
|
|
19
19
|
def with_context(context_key, options = {}, &block)
|
|
20
20
|
if !config.contexts.key?(context_key)
|
|
21
|
-
raise ArgumentError,
|
|
21
|
+
raise ArgumentError,
|
|
22
|
+
"#{context_key} not found in Retriable.config.contexts. Available contexts: #{config.contexts.keys}"
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
return unless block_given?
|
|
@@ -33,20 +34,21 @@ module Retriable
|
|
|
33
34
|
intervals = build_intervals(local_config, tries)
|
|
34
35
|
timeout = local_config.timeout
|
|
35
36
|
on = local_config.on
|
|
37
|
+
retry_if = local_config.retry_if
|
|
36
38
|
on_retry = local_config.on_retry
|
|
37
39
|
sleep_disabled = local_config.sleep_disabled
|
|
38
40
|
max_elapsed_time = local_config.max_elapsed_time
|
|
39
41
|
|
|
40
42
|
exception_list = on.is_a?(Hash) ? on.keys : on
|
|
41
43
|
exception_list = [*exception_list]
|
|
42
|
-
start_time =
|
|
43
|
-
elapsed_time = -> {
|
|
44
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
45
|
+
elapsed_time = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time }
|
|
44
46
|
|
|
45
47
|
tries = intervals.size + 1
|
|
46
48
|
|
|
47
49
|
execute_tries(
|
|
48
50
|
tries: tries, intervals: intervals, timeout: timeout,
|
|
49
|
-
exception_list: exception_list, on: on, on_retry: on_retry,
|
|
51
|
+
exception_list: exception_list, on: on, retry_if: retry_if, on_retry: on_retry,
|
|
50
52
|
elapsed_time: elapsed_time, max_elapsed_time: max_elapsed_time,
|
|
51
53
|
sleep_disabled: sleep_disabled, &block
|
|
52
54
|
)
|
|
@@ -54,7 +56,7 @@ module Retriable
|
|
|
54
56
|
|
|
55
57
|
def execute_tries( # rubocop:disable Metrics/ParameterLists
|
|
56
58
|
tries:, intervals:, timeout:, exception_list:,
|
|
57
|
-
on:, on_retry:, elapsed_time:, max_elapsed_time:, sleep_disabled:, &block
|
|
59
|
+
on:, retry_if:, on_retry:, elapsed_time:, max_elapsed_time:, sleep_disabled:, &block
|
|
58
60
|
)
|
|
59
61
|
tries.times do |index|
|
|
60
62
|
try = index + 1
|
|
@@ -62,7 +64,7 @@ module Retriable
|
|
|
62
64
|
begin
|
|
63
65
|
return call_with_timeout(timeout, try, &block)
|
|
64
66
|
rescue *exception_list => e
|
|
65
|
-
raise unless retriable_exception?(e, on, exception_list)
|
|
67
|
+
raise unless retriable_exception?(e, on, exception_list, retry_if)
|
|
66
68
|
|
|
67
69
|
interval = intervals[index]
|
|
68
70
|
call_on_retry(on_retry, e, try, elapsed_time.call, interval)
|
|
@@ -99,15 +101,20 @@ module Retriable
|
|
|
99
101
|
end
|
|
100
102
|
|
|
101
103
|
def can_retry?(try, tries, elapsed_time, interval, max_elapsed_time)
|
|
102
|
-
try < tries
|
|
104
|
+
return false unless try < tries
|
|
105
|
+
return true if max_elapsed_time.nil?
|
|
106
|
+
|
|
107
|
+
(elapsed_time + interval) <= max_elapsed_time
|
|
103
108
|
end
|
|
104
109
|
|
|
105
110
|
# When `on` is a Hash, we need to verify the exception matches a pattern.
|
|
106
111
|
# For any non-Hash `on` value (e.g., Array of classes, single Exception class,
|
|
107
112
|
# or Module), the `rescue *exception_list` clause already guarantees the
|
|
108
|
-
# exception is retriable
|
|
109
|
-
|
|
113
|
+
# exception is retriable with respect to `on`; `retry_if`, if provided, is an
|
|
114
|
+
# additional gate that can still cause this method to return false.
|
|
115
|
+
def retriable_exception?(exception, on, exception_list, retry_if)
|
|
110
116
|
return false if on.is_a?(Hash) && !hash_exception_match?(exception, on, exception_list)
|
|
117
|
+
return false if retry_if && !retry_if.call(exception)
|
|
111
118
|
|
|
112
119
|
true
|
|
113
120
|
end
|
data/spec/config_spec.rb
CHANGED
|
@@ -40,6 +40,10 @@ describe Retriable::Config do
|
|
|
40
40
|
expect(default_config.on).to eq([StandardError])
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
it "retry_if defaults to nil" do
|
|
44
|
+
expect(default_config.retry_if).to be_nil
|
|
45
|
+
end
|
|
46
|
+
|
|
43
47
|
it "on_retry handler defaults to nil" do
|
|
44
48
|
expect(default_config.on_retry).to be_nil
|
|
45
49
|
end
|
data/spec/retriable_spec.rb
CHANGED
|
@@ -259,6 +259,50 @@ describe Retriable do
|
|
|
259
259
|
end
|
|
260
260
|
end
|
|
261
261
|
|
|
262
|
+
context "with a :retry_if parameter" do
|
|
263
|
+
it "retries only when retry_if returns true" do
|
|
264
|
+
described_class.retriable(tries: 3, retry_if: ->(_exception) { @tries < 3 }) do
|
|
265
|
+
increment_tries
|
|
266
|
+
raise StandardError, "StandardError occurred" if @tries < 3
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
expect(@tries).to eq(3)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
it "does not retry when retry_if returns false" do
|
|
273
|
+
expect do
|
|
274
|
+
described_class.retriable(tries: 3, retry_if: ->(_exception) { false }) do
|
|
275
|
+
increment_tries_with_exception
|
|
276
|
+
end
|
|
277
|
+
end.to raise_error(StandardError)
|
|
278
|
+
|
|
279
|
+
expect(@tries).to eq(1)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
it "can retry based on the wrapped exception cause" do
|
|
283
|
+
root_cause_class = Class.new(StandardError)
|
|
284
|
+
wrapper_class = Class.new(StandardError)
|
|
285
|
+
|
|
286
|
+
described_class.retriable(
|
|
287
|
+
on: [wrapper_class],
|
|
288
|
+
tries: 3,
|
|
289
|
+
retry_if: ->(exception) { exception.cause.is_a?(root_cause_class) },
|
|
290
|
+
) do
|
|
291
|
+
increment_tries
|
|
292
|
+
|
|
293
|
+
if @tries < 3
|
|
294
|
+
begin
|
|
295
|
+
raise root_cause_class, "root cause"
|
|
296
|
+
rescue root_cause_class
|
|
297
|
+
raise wrapper_class, "wrapped"
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
expect(@tries).to eq(3)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
262
306
|
it "runs for a max elapsed time of 2 seconds" do
|
|
263
307
|
described_class.configure { |c| c.sleep_disabled = false }
|
|
264
308
|
|
|
@@ -271,6 +315,38 @@ describe Retriable do
|
|
|
271
315
|
expect(@tries).to eq(2)
|
|
272
316
|
end
|
|
273
317
|
|
|
318
|
+
it "retries up to tries limit when max_elapsed_time is nil" do
|
|
319
|
+
expect do
|
|
320
|
+
described_class.retriable(tries: 4, max_elapsed_time: nil) { increment_tries_with_exception }
|
|
321
|
+
end.to raise_error(StandardError)
|
|
322
|
+
|
|
323
|
+
expect(@tries).to eq(4)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
it "uses monotonic clock for elapsed time tracking" do
|
|
327
|
+
# Stub Process.clock_gettime to return controlled values so we can
|
|
328
|
+
# verify elapsed_time passed to on_retry is derived from the monotonic clock.
|
|
329
|
+
clock_calls = 0
|
|
330
|
+
allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC) do
|
|
331
|
+
value = clock_calls.to_f
|
|
332
|
+
clock_calls += 1
|
|
333
|
+
value
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
elapsed_times = []
|
|
337
|
+
on_retry = ->(_exception, _try, elapsed_time, _next_interval) { elapsed_times << elapsed_time }
|
|
338
|
+
|
|
339
|
+
expect do
|
|
340
|
+
described_class.retriable(tries: 3, on_retry: on_retry) { increment_tries_with_exception }
|
|
341
|
+
end.to raise_error(StandardError)
|
|
342
|
+
|
|
343
|
+
# start_time (call 0) + at least one elapsed_time computation per retry
|
|
344
|
+
expect(clock_calls).to be >= 3
|
|
345
|
+
# elapsed_time values should be positive and non-decreasing
|
|
346
|
+
expect(elapsed_times).to all(be > 0)
|
|
347
|
+
expect(elapsed_times).to eq(elapsed_times.sort)
|
|
348
|
+
end
|
|
349
|
+
|
|
274
350
|
it "raises ArgumentError on invalid options" do
|
|
275
351
|
expect { described_class.retriable(does_not_exist: 123) { increment_tries } }.to raise_error(ArgumentError)
|
|
276
352
|
end
|