retriable 3.6.1 → 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: 9120a536b473da5668754cf8d0021abc06200c558bc916091b871efb24c60f4c
4
- data.tar.gz: eb4eaf3b7509cf88c57609a70b83b8fdc75da2727c05a7a0cd5daadf36b601d4
3
+ metadata.gz: a5f3739e08167965e6f60cc6104d78f46709c323c2b1a80a839afcd5049ba3ca
4
+ data.tar.gz: f9d5f2fe821d7629ceb6954c0afd1a99f63d8b6c99cecc9575e9b5672489be31
5
5
  SHA512:
6
- metadata.gz: 0123eb6bbcdb8d392bb5b4b7eab3c59db53970a8bd48b2a320c2d5b085e9196dc2918fd9934f054755069d7cbc7f3d434cac87d2878f2b4ae767d138e5b03d16
7
- data.tar.gz: 8d09ecc45ce61a5c76e0afd319b6ddbdb37ae99477f9ec0b6faf81caf6bfbd678abb1336b4254a15350efb2c2bede124625722707241729ed74169d8dfae5a9d
6
+ metadata.gz: f5d91b6ffed2805e0da09e307da9cddf018647c1ff89e040da96c9bffd26b5929465694d251c33fc19927288568b2ada4a52f3c1c50b384423038c0eb578634a
7
+ data.tar.gz: 1f1e5f0f352a34831f2e71eda177b88336a4d7571be1f4370d98a151e7d06b65468b7d05f02310087e322611149d751e8a271de2c6931bdc8efef9a5f1cb335f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
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
+
3
7
  ## 3.6.1
4
8
 
5
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.
data/README.md CHANGED
@@ -104,7 +104,7 @@ Here are the available options, in some vague order of relevance to most common
104
104
 
105
105
  | Option | Default | Definition |
106
106
  | ---------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
107
- | **`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. |
108
108
  | **`on`** | `[StandardError]` | Type of exceptions to retry. [Read more](#configuring-which-options-to-retry-with-on). |
109
109
  | **`retry_if`** | `nil` | Callable (for example a `Proc` or lambda) that receives the rescued exception and returns true/false to decide whether to retry. [Read more](#advanced-retry-matching-with-retry_if). |
110
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). |
@@ -117,7 +117,7 @@ Here are the available options, in some vague order of relevance to most common
117
117
  | **`intervals`** | `nil` | Skip generated intervals and provide your own array of intervals in seconds. [Read more](#custom-interval-array). |
118
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. |
119
119
 
120
- Timing options are validated before retrying. `tries` must be a positive integer when Retriable generates intervals. `base_interval`, `max_interval`, `multiplier`, `max_elapsed_time`, and `timeout` must be non-negative numbers, with `max_elapsed_time` and `timeout` also accepting `nil`. `rand_factor` must be a number from `0` through `1`. If provided, `intervals` must be an array of non-negative numbers; because it replaces generated intervals, it also overrides `tries`, `base_interval`, `max_interval`, `rand_factor`, and `multiplier` validation.
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`.
121
121
 
122
122
  #### Configuring Which Options to Retry With :on
123
123
 
@@ -273,6 +273,21 @@ end
273
273
 
274
274
  This example makes 5 total attempts. If the first attempt fails, the 2nd attempt occurs 0.5 seconds later.
275
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
+
276
291
  ### Turn off Exponential Backoff
277
292
 
278
293
  Exponential backoff is enabled by default. If you want to simply retry code every second, 5 times maximum, you can do this:
@@ -53,20 +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)
58
57
  validate_on(on)
59
58
  validate_intervals
60
- 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
61
64
 
62
- 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
63
74
  validate_non_negative_number(:base_interval, base_interval)
64
75
  validate_non_negative_number(:multiplier, multiplier)
65
76
  validate_non_negative_number(:max_interval, max_interval)
66
77
  validate_rand_factor
67
78
  end
68
79
 
69
- 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
70
93
 
71
94
  def validate_intervals
72
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
@@ -38,6 +38,12 @@ module Retriable
38
38
  value.is_a?(Numeric) && value.to_f.finite?
39
39
  end
40
40
 
41
+ def unbounded_tries?(value)
42
+ value.is_a?(Numeric) && value.respond_to?(:infinite?) && value.infinite? == 1
43
+ end
44
+
45
+ module_function :unbounded_tries?
46
+
41
47
  # Validates an `on:` value. Acceptable shapes:
42
48
  # - a Class that descends from Exception
43
49
  # - an Array whose elements are Classes that descend from Exception
@@ -73,7 +79,10 @@ module Retriable
73
79
  def validate_on_hash_value(klass, pattern)
74
80
  return if pattern.nil?
75
81
  return if pattern.is_a?(Regexp)
82
+ # Ruby 2.3 does not support Enumerable#all?(pattern).
83
+ # rubocop:disable Style/PredicateWithKind
76
84
  return if pattern.is_a?(Array) && pattern.all? { |p| p.is_a?(Regexp) }
85
+ # rubocop:enable Style/PredicateWithKind
77
86
 
78
87
  raise ArgumentError,
79
88
  "on[#{klass}] must be nil, a Regexp, or an Array of Regexps, got #{pattern.inspect}"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Retriable
4
- VERSION = "3.6.1"
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)
122
+
123
+ RetryPlan.new(max_tries, ->(index) { index < max_tries - 1 ? provider.call(index) : nil })
124
+ end
114
125
 
126
+ def interval_provider(local_config)
115
127
  ExponentialBackoff.new(
116
- tries: tries - 1,
117
128
  base_interval: local_config.base_interval,
118
129
  multiplier: local_config.multiplier,
119
130
  max_interval: local_config.max_interval,
120
131
  rand_factor: local_config.rand_factor,
121
- ).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
@@ -231,7 +242,8 @@ module Retriable
231
242
  :validate_override_options,
232
243
  :validate_context_override_options,
233
244
  :execute_tries,
234
- :build_intervals,
245
+ :retry_plan,
246
+ :interval_provider,
235
247
  :call_with_timeout,
236
248
  :call_on_retry,
237
249
  :can_retry?,
data/spec/config_spec.rb CHANGED
@@ -66,6 +66,26 @@ describe Retriable::Config do
66
66
  expect { described_class.new(intervals: "1") }.to raise_error(ArgumentError, /intervals must be an Array/)
67
67
  end
68
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
+
69
89
  context "on: option validation" do
70
90
  it "accepts a single Exception subclass" do
71
91
  expect { described_class.new(on: StandardError) }.not_to raise_error
@@ -22,17 +22,19 @@ describe Retriable::ExponentialBackoff do
22
22
  end
23
23
 
24
24
  it "generates 10 randomized intervals" do
25
- expect(described_class.new(tries: 9).intervals).to eq([
26
- 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
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.1
4
+ version: 3.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jack Chu
@@ -74,7 +74,6 @@ files:
74
74
  - Rakefile
75
75
  - bin/console
76
76
  - bin/setup
77
- - docs/superpowers/specs/2026-05-26-on-give-up-callback-followups-design.md
78
77
  - docs/testing.md
79
78
  - lib/retriable.rb
80
79
  - lib/retriable/config.rb
@@ -1,116 +0,0 @@
1
- # Design: `on_give_up` callback follow-ups (issue #72, PR #127)
2
-
3
- ## Context
4
-
5
- - Issue [#72](https://github.com/kamui/retriable/issues/72) requests a callback that fires only after all retries are exhausted.
6
- - Draft PR [#127](https://github.com/kamui/retriable/pull/127) (branch `feat/on-give-up-callback`, authored by maintainer @kamui) implements this as `on_give_up`. It is largely production-ready.
7
-
8
- This spec defines a small set of follow-up additions on top of `feat/on-give-up-callback`. It does **not** revisit the design decisions already settled in #127 (naming, signature, reason symbols, opt-out behavior).
9
-
10
- ## Settled decisions inherited from #127
11
-
12
- | Decision | Resolution |
13
- | --- | --- |
14
- | Callback name | `on_give_up` |
15
- | Signature | `(exception, try, elapsed_time, next_interval, reason)` |
16
- | Reason values | `:tries_exhausted`, `:max_elapsed_time` |
17
- | `next_interval` when `:tries_exhausted` | `nil` |
18
- | `next_interval` when `:max_elapsed_time` | The interval that would have been slept before the next try |
19
- | Opt-out | `on_give_up: false` (or `nil`) disables a configured handler |
20
- | Order vs `on_retry` | `on_retry` runs first; `on_give_up` runs just before re-raise |
21
- | Non-retriable exception types | `on_give_up` does **not** fire |
22
- | `retry_if` rejection | `on_give_up` does **not** fire |
23
- | `elapsed_time` for the give-up decision | Re-read after `on_retry` returns so handler time counts toward `max_elapsed_time` |
24
- | Threading through `Config::ATTRIBUTES` | Already enables `with_context`, `override`, and `configure` automatically |
25
-
26
- ## Gaps to fill
27
-
28
- PR #127 has the mechanics right. The follow-up work below closes documentation and test-coverage gaps and locks in undocumented-but-implied semantics.
29
-
30
- ### 1. Document non-firing cases in README
31
-
32
- PR #127 covers the firing cases. The README should explicitly state when the callback does **not** fire, because users wiring up paging/metrics need to know.
33
-
34
- Add one paragraph at the end of the new `on_give_up` subsection in `README.md`:
35
-
36
- > `on_give_up` is invoked only when Retriable rescued an exception that matched the retry rules and then decided to stop. It does **not** fire when the block raises an exception that is not in `on`, nor when `retry_if` returns false. Both of those cases are immediate re-raises, not retry exhaustion, and should be handled with normal Ruby `rescue` blocks around the `Retriable.retriable` call.
37
-
38
- ### 2. Document handler-raised-error policy in README
39
-
40
- Current `on_retry` documentation does not state what happens if the handler itself raises. PR #127 silently inherits the same behavior: an exception inside `on_give_up` propagates, replacing the original. Make this explicit.
41
-
42
- Add one sentence to the same subsection:
43
-
44
- > If `on_give_up` itself raises, that exception propagates to the caller and replaces the original retried exception. Keep the handler defensive (rescue inside it) if you need the original exception to surface.
45
-
46
- ### 3. Mention `on_give_up` in the Contexts example
47
-
48
- `README.md` already has a Contexts example at `README.md:306`. Extend the `:aws` context to demonstrate `on_give_up`:
49
-
50
- ```ruby
51
- Retriable.configure do |c|
52
- c.contexts[:aws] = {
53
- tries: 3,
54
- base_interval: 5,
55
- on_retry: Proc.new { puts 'Curse you, AWS!' },
56
- on_give_up: Proc.new { |_e, _try, _elapsed, _interval, reason|
57
- puts "Gave up on AWS: #{reason}"
58
- },
59
- }
60
- end
61
- ```
62
-
63
- ### 4. Test: per-context `override` accepts and dispatches `on_give_up`
64
-
65
- PR #127 adds a positive `with_context` spec and an `override` spec for top-level overrides, but no spec for the call shape `Retriable.override(contexts: { key: { on_give_up: ... } })`, which is validated by `validate_context_override_options` and applied by `context_options_for`. Add one spec under the existing `#override` context that:
66
-
67
- 1. Calls `Retriable.override(contexts: { api: { on_give_up: handler, tries: 1 } })`.
68
- 2. Invokes `Retriable.with_context(:api) { raise StandardError }`.
69
- 3. Asserts the handler was invoked exactly once with `reason == :tries_exhausted`.
70
-
71
- ### 5. Test: kernel extension passes `on_give_up` through
72
-
73
- PR #127 does not exercise the kernel extension (`Kernel#retriable` and `Kernel#retriable_with_context`). The delegation is trivial, but a regression guard is cheap. Add one spec inside the existing `context "global scope extension"` block that requires `retriable/core_ext/kernel`, invokes `retriable(tries: 1, on_give_up: handler) { raise }`, and asserts the handler ran with `reason == :tries_exhausted`. A second `retriable_with_context` spec is not needed because item 4 already covers the context-dispatch path.
74
-
75
- ### 6. Test: handler that raises propagates and replaces the original
76
-
77
- Lock in the policy from item 2 with a spec: handler raises `RuntimeError`, caller observes `RuntimeError`, not the original `StandardError`.
78
-
79
- ### 7. CHANGELOG entry: include signature and reasons
80
-
81
- PR #127's CHANGELOG line is:
82
-
83
- > - Add `on_give_up` callback to observe when retries stop because tries are exhausted or the next retry would exceed `max_elapsed_time`.
84
-
85
- Rewrite to:
86
-
87
- > - Add `on_give_up` callback that runs when Retriable stops retrying after a rescued retriable exception. Receives `(exception, try, elapsed_time, next_interval, reason)`, where `reason` is `:tries_exhausted` or `:max_elapsed_time`. Does not fire for non-retriable exceptions or `retry_if` rejections. Pass `on_give_up: false` to suppress a configured handler for a single call.
88
-
89
- ## Out of scope
90
-
91
- - Renaming `on_give_up`. The maintainer authored the draft with this name.
92
- - Changing the callback signature (e.g., removing `next_interval`).
93
- - Firing for `retry_if` rejection. That decision was made deliberately in #127.
94
- - Version bump. Deferred to the maintainer's release commit.
95
- - Touching the pre-existing rubocop offenses noted in PR #127's description (`retriable.gemspec`, `spec/exponential_backoff_spec.rb`).
96
-
97
- ## Files touched
98
-
99
- - `README.md` — items 1, 2, 3.
100
- - `spec/retriable_spec.rb` — items 4, 5, 6.
101
- - `CHANGELOG.md` — item 7.
102
-
103
- No changes to `lib/retriable.rb` or `lib/retriable/config.rb`; PR #127's implementation already satisfies the behavior.
104
-
105
- ## Verification
106
-
107
- ```sh
108
- bundle exec rspec
109
- bundle exec rubocop lib spec
110
- ```
111
-
112
- Both must pass. Pre-existing rubocop offenses in `retriable.gemspec` and `spec/exponential_backoff_spec.rb` are intentionally left untouched (see Out of scope).
113
-
114
- ## Delivery
115
-
116
- Push as additional commits on the existing `feat/on-give-up-callback` branch (PR #127). If we lack push access to the maintainer's branch, open a PR targeting `feat/on-give-up-callback` with these follow-ups, or post the diff as a review comment on #127.