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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 575fe838c7ddda74e7e26abf2a3b74a9e4b6866bf20d33a86bc59d1b7c45770d
4
- data.tar.gz: f8d81648c9ea122680f3e8eb2f71b7cbbfffc0049313123da850378bf5b3f77d
3
+ metadata.gz: a5f3739e08167965e6f60cc6104d78f46709c323c2b1a80a839afcd5049ba3ca
4
+ data.tar.gz: f9d5f2fe821d7629ceb6954c0afd1a99f63d8b6c99cecc9575e9b5672489be31
5
5
  SHA512:
6
- metadata.gz: bff5a1d9a0efec882841085a306290d11ccd9f20c0622b8a27c733c13981a68c681a562d80eb267c17b866a816a03d514a92b4a17883a1d4180d835f2b76fd02
7
- data.tar.gz: 9da2cfea5cf807d352ed899926b0fa9e45d825935697788163dfbd5cf7c47afe7b0cfd7b20af5b29ce61f84d5d923181b7a671e21e3cafd709b15f922c68abf8
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:
@@ -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
- return if intervals
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
- validate_positive_integer(:tries, tries)
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
- private
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
- intervals = Array.new(tries) do |iteration|
37
- [base_interval * (multiplier**iteration), max_interval].min
38
- end
36
+ provider = interval_provider
37
+ Array.new(tries) { |iteration| provider.call(iteration) }
38
+ end
39
39
 
40
- return intervals if rand_factor.zero?
40
+ def interval_provider
41
+ raw_interval = base_interval
41
42
 
42
- intervals.map { |i| randomize(i) }
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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Retriable
4
- VERSION = "3.6.0"
4
+ VERSION = "3.7.0"
5
5
  end
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
- tries = local_config.tries
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
- tries: tries, intervals: intervals, timeout: timeout,
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
- tries:, intervals:, timeout:, exception_list:,
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
- tries.times do |index|
94
- try = index + 1
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 = intervals[index]
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, tries, elapsed_time.call, elapsed_interval, max_elapsed_time)
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 build_intervals(local_config, tries)
113
- return local_config.intervals if local_config.intervals
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
- ).intervals
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, tries, elapsed_time, interval, max_elapsed_time)
137
- return false unless try < tries
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 unless contexts.is_a?(Hash)
183
+ return if contexts.nil?
171
184
 
172
- contexts.each_value do |context_options|
173
- validate_context_override_options(context_options)
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
- return unless context_options.is_a?(Hash)
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
- :build_intervals,
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
- 0.5244067512211441,
27
- 0.9113920238761231,
28
- 1.2406087918999114,
29
- 1.7632403621664823,
30
- 2.338001204738311,
31
- 4.350816718580626,
32
- 5.339852157217869,
33
- 11.889873261212443,
34
- 18.756037881636484,
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
- 1.0488135024422882,
45
- 1.8227840477522461,
46
- 2.4812175837998227,
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
- 0.5244067512211441,
53
- 0.607594682584082,
54
- 0.5513816852888495,
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
- 0.5097627004884576,
65
- 0.8145568095504492,
66
- 1.1712435167599646,
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
@@ -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 "ignores non-hash override contexts values in with_context" do
591
- described_class.configure do |c|
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) do
596
- described_class.with_context(:api) { increment_tries }
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(1)
709
+ expect(@tries).to eq(2)
600
710
  end
601
711
 
602
- it "ignores non-hash per-context override values in with_context" do
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: 2 }
714
+ c.contexts[:api] = { tries: 10 }
605
715
  end
606
716
 
607
- described_class.with_override(contexts: { api: 123 }) do
608
- expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
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)
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.6.0
4
+ version: 3.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jack Chu