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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c0e8971cb9e6833e2795f2537ee69950714f7eb85989b497d15fad7f2e2552b6
4
- data.tar.gz: 222c0e1664bf327dfb22cf161c42a3f3bec07a54ea3787af24a86ebd86abcd50
3
+ metadata.gz: 86086d1d868b9f609e96d2b5e8e5f6e5910a9822b7c21dd86a4014a12105d7f3
4
+ data.tar.gz: a7324a228f6b0565428cb10afe6ecf3a98b7a6984ced4f83c32595f97767b09b
5
5
  SHA512:
6
- metadata.gz: b768d200c1dd25f9a5218096e5bba26b0ee815833fb87ed6a004d90d7f94208f4520df0fdb1172fc6fdea55209cf1eeb15d6efc39a52bd2f32c0222b5aecca5a
7
- data.tar.gz: 0a095f55fcdbe49adf71bb06682c062ed98a4328ef0e4648926e6782b93152c337cd78b2f30a2bf5a79affc37c31c3f2b39730600c3c63b5dcb41d5c09b59380
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.1'
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:
@@ -4,14 +4,15 @@ require_relative "exponential_backoff"
4
4
 
5
5
  module Retriable
6
6
  class Config
7
- ATTRIBUTES = (ExponentialBackoff::ATTRIBUTES + [
8
- :sleep_disabled,
9
- :max_elapsed_time,
10
- :intervals,
11
- :timeout,
12
- :on,
13
- :on_retry,
14
- :contexts,
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 * 1.0
42
+ delta = rand_factor * interval.to_f
43
43
  min = interval - delta
44
44
  max = interval + delta
45
45
  rand(min..max)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Retriable
4
- VERSION = "3.3.0"
4
+ VERSION = "3.4.1"
5
5
  end
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, "#{context_key} not found in Retriable.config.contexts. Available contexts: #{config.contexts.keys}"
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 = Time.now
43
- elapsed_time = -> { Time.now - start_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 && (elapsed_time + interval) <= max_elapsed_time
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, so we return true unconditionally.
109
- def retriable_exception?(exception, on, exception_list)
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
@@ -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
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.3.0
4
+ version: 3.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jack Chu