retriable 3.6.1 → 3.8.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: 25948e6545b8d98b0c7b08416bdf23b017ced352eb2a4eb1f2e9fdac45912031
4
+ data.tar.gz: 4a92afb17f79a4ad173b046158ca634b0728fc6ccceedc1b7789c9106342b513
5
5
  SHA512:
6
- metadata.gz: 0123eb6bbcdb8d392bb5b4b7eab3c59db53970a8bd48b2a320c2d5b085e9196dc2918fd9934f054755069d7cbc7f3d434cac87d2878f2b4ae767d138e5b03d16
7
- data.tar.gz: 8d09ecc45ce61a5c76e0afd319b6ddbdb37ae99477f9ec0b6faf81caf6bfbd678abb1336b4254a15350efb2c2bede124625722707241729ed74169d8dfae5a9d
6
+ metadata.gz: e43731b305a64c806bf57c13d78da410412d8ea4adaec9f255bd434b2d190ac53b6fac29172b72ed6bcc7329a3d5378c4075d72f6dede791f1e0bd16f400e58e
7
+ data.tar.gz: 4259468487738b9ae5bcc817ac992af3341f15bcfe604ab0a61ddf1f8793b444775aa57c06ce5f95561e179186e7360e86a877afd02f3b346b68377ffb3ecaf9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # HEAD
2
2
 
3
+ ## 3.8.0
4
+
5
+ ### Deprecations
6
+
7
+ - Deprecated the `timeout:` option ahead of its removal in Retriable 4.0. Non-nil timeout values supplied through `Retriable.configure`, `Retriable.retriable(...)`, or `Retriable.with_override(...)` now emit a deprecation warning while keeping the existing runtime behavior unchanged. On Ruby 2.7+ the warning is emitted via `Kernel.warn(..., category: :deprecated)`, so callers can silence it through the standard Ruby controls (`Warning[:deprecated] = false`, `ruby -W:no-deprecated`, or a custom `Warning.warn`). To keep the notice from drowning busy applications, it is emitted at most once per process; suppression via `Warning[:deprecated]` leaves the warner armed for the next call that re-enables the category. Prefer library-native timeout settings, or wrap the retried block in `Timeout.timeout(...)` directly if you still need that behavior. See the README migration guidance for details.
8
+
9
+ ## 3.7.0
10
+
11
+ - 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!`.
12
+
3
13
  ## 3.6.1
4
14
 
5
15
  - 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
@@ -17,6 +17,7 @@ Retriable is a simple DSL to retry failed code blocks with randomized [exponenti
17
17
  - [Configuration](#configuration)
18
18
  - [Override](#override)
19
19
  - [Example Usage](#example-usage)
20
+ - [Migrating off `timeout:`](#migrating-off-timeout)
20
21
  - [Custom Interval Array](#custom-interval-array)
21
22
  - [Turn off Exponential Backoff](#turn-off-exponential-backoff)
22
23
  - [Callbacks](#callbacks)
@@ -55,7 +56,7 @@ require 'retriable'
55
56
  In your Gemfile:
56
57
 
57
58
  ```ruby
58
- gem 'retriable', '~> 3.6'
59
+ gem 'retriable', '~> 3.8'
59
60
  ```
60
61
 
61
62
  ## Usage
@@ -102,22 +103,22 @@ The default interval table with 10 tries looks like this (in seconds, rounded to
102
103
 
103
104
  Here are the available options, in some vague order of relevance to most common use patterns:
104
105
 
105
- | Option | Default | Definition |
106
- | ---------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
107
- | **`tries`** | `3` | Number of attempts to make at running your code block (includes initial attempt). |
108
- | **`on`** | `[StandardError]` | Type of exceptions to retry. [Read more](#configuring-which-options-to-retry-with-on). |
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
- | **`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). |
111
- | **`sleep_disabled`** | `false` | When true, disable exponential backoff and attempt retries immediately. |
112
- | **`base_interval`** | `0.5` | The initial interval in seconds between tries. |
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`. |
114
- | **`max_interval`** | `60` | The maximum interval in seconds that any individual retry can reach. |
115
- | **`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. |
116
- | **`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])` |
117
- | **`intervals`** | `nil` | Skip generated intervals and provide your own array of intervals in seconds. [Read more](#custom-interval-array). |
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
-
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.
106
+ | Option | Default | Definition |
107
+ | ---------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
108
+ | **`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. |
109
+ | **`on`** | `[StandardError]` | Type of exceptions to retry. [Read more](#configuring-which-options-to-retry-with-on). |
110
+ | **`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). |
111
+ | **`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). |
112
+ | **`sleep_disabled`** | `false` | When true, disable exponential backoff and attempt retries immediately. |
113
+ | **`base_interval`** | `0.5` | The initial interval in seconds between tries. |
114
+ | **`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`. |
115
+ | **`max_interval`** | `60` | The maximum interval in seconds that any individual retry can reach. |
116
+ | **`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. |
117
+ | **`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])` |
118
+ | **`intervals`** | `nil` | Skip generated intervals and provide your own array of intervals in seconds. [Read more](#custom-interval-array). |
119
+ | **`timeout`** | `nil` | Deprecated in 3.8.0 and removed in 4.0. 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. Non-nil values emit a deprecation warning. See [Migrating off `timeout:`](#migrating-off-timeout). |
120
+
121
+ 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
122
 
122
123
  #### Configuring Which Options to Retry With :on
123
124
 
@@ -243,20 +244,32 @@ Retriable.retriable(on: {
243
244
  end
244
245
  ```
245
246
 
246
- You can also specify a timeout if you want the code block to only try for X amount of seconds. This timeout is per try.
247
+ #### Migrating off `timeout:`
247
248
 
248
- 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/). You can use this option, but you need to be very careful because the code in the block, including libraries or other code it calls, could be interrupted by the timeout at any line. You must ensure you have the right rescue logic and guards in place ([Thread.handle_interrupt](https://www.rubydoc.info/stdlib/core/Thread.handle_interrupt)) to handle that possible behavior. If that's not possible, the recommendation is that you're better off impelenting your own timeout methods depending on what your code is doing than use this feature.
249
+ The `timeout:` option is deprecated in Retriable 3.8.0 and will be removed in Retriable 4.0. It still works in 3.x, but any non-nil value supplied through `Retriable.configure`, `Retriable.retriable(...)`, or `Retriable.with_override(...)` emits a deprecation warning. In Retriable 4.0, passing `timeout:` will raise `ArgumentError` because it will no longer be a valid option.
250
+
251
+ `timeout:` is deprecated because it is a thin wrapper around `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/). It can interrupt the retried block at any line, including inside libraries that are not interrupt-safe.
252
+
253
+ Prefer timeout settings from the library you are calling, such as `Net::HTTP#read_timeout`, `Net::HTTP#open_timeout`, or Faraday's request timeout options. If you still need `Timeout.timeout`, wrap the retried block explicitly so the risk is visible at the call site:
249
254
 
250
255
  ```ruby
251
- Retriable.retriable(timeout: 60) do
252
- # code here...
256
+ require "timeout"
257
+
258
+ Retriable.retriable(on: Timeout::Error, tries: 3) do
259
+ Timeout.timeout(5) do
260
+ # code here...
261
+ end
253
262
  end
254
263
  ```
255
264
 
256
- If you need millisecond units of time for the sleep or the timeout:
265
+ Like the deprecated `timeout:` option, `Timeout.timeout(5)` inside the block is per-try — each retry gets a fresh 5-second budget. If you want an overall cap across all retries instead, prefer `max_elapsed_time:`.
266
+
267
+ The deprecation warning is emitted under the `:deprecated` warning category and at most once per process. To silence it (for example, in tests), use the standard Ruby controls — set `Warning[:deprecated] = false`, run with `ruby -W:no-deprecated`, or override `Warning.warn` to filter the message.
268
+
269
+ If you need millisecond units of time for the sleep interval:
257
270
 
258
271
  ```ruby
259
- Retriable.retriable(base_interval: (200 / 1000.0), timeout: (500 / 1000.0)) do
272
+ Retriable.retriable(base_interval: (200 / 1000.0)) do
260
273
  # code here...
261
274
  end
262
275
  ```
@@ -273,6 +286,21 @@ end
273
286
 
274
287
  This example makes 5 total attempts. If the first attempt fails, the 2nd attempt occurs 0.5 seconds later.
275
288
 
289
+ ### Unbounded Retries (Opt-in)
290
+
291
+ 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.
292
+
293
+ ```ruby
294
+ Retriable.retriable(tries: Float::INFINITY, max_elapsed_time: 300) do
295
+ # code here...
296
+ end
297
+ ```
298
+
299
+ When `tries: Float::INFINITY` is set:
300
+
301
+ - `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.
302
+ - Custom `intervals:` cannot be combined with `Float::INFINITY` and raises `ArgumentError`. Use the exponential backoff settings (`base_interval`, `multiplier`, `max_interval`, `rand_factor`) instead.
303
+
276
304
  ### Turn off Exponential Backoff
277
305
 
278
306
  Exponential backoff is enabled by default. If you want to simply retry code every second, 5 times maximum, you can do this:
@@ -18,6 +18,19 @@ module Retriable
18
18
  contexts
19
19
  ]).freeze
20
20
 
21
+ TIMEOUT_DEPRECATION_MESSAGE = "NOTE: Retriable's `timeout:` option is deprecated and will be removed in " \
22
+ "Retriable 4.0. It is a thin wrapper around `Timeout.timeout`, which " \
23
+ "can interrupt execution at arbitrary lines and corrupt internal state " \
24
+ "in libraries that are not interrupt-safe. Prefer your library's native " \
25
+ "timeout, or wrap your block in `Timeout.timeout(...)` yourself."
26
+ private_constant :TIMEOUT_DEPRECATION_MESSAGE
27
+
28
+ @timeout_deprecation_warned = false
29
+
30
+ class << self
31
+ attr_accessor :timeout_deprecation_warned
32
+ end
33
+
21
34
  attr_accessor(*ATTRIBUTES)
22
35
 
23
36
  def initialize(opts = {})
@@ -53,20 +66,81 @@ module Retriable
53
66
  end
54
67
 
55
68
  def validate!
56
- validate_optional_non_negative_number(:max_elapsed_time, max_elapsed_time)
69
+ warn_timeout_deprecation
57
70
  validate_optional_non_negative_number(:timeout, timeout)
58
71
  validate_on(on)
59
72
  validate_intervals
60
- return if intervals
73
+ if unbounded_tries?(tries)
74
+ validate_unbounded_tries
75
+ else
76
+ validate_optional_non_negative_number(:max_elapsed_time, max_elapsed_time)
77
+ return if intervals
78
+
79
+ validate_positive_integer(:tries, tries)
80
+ end
81
+
82
+ validate_backoff_options
83
+ end
84
+
85
+ private
86
+
87
+ # Emits the `timeout:` deprecation notice at most once per process.
88
+ #
89
+ # On Rubies that support `Kernel#warn(category: :deprecated)` (2.7+), the
90
+ # notice is emitted under the `:deprecated` category, so callers can use the
91
+ # standard controls (`Warning[:deprecated] = false`, `-W:no-deprecated`,
92
+ # `Warning.warn` override) to silence it. On older Rubies the kwarg is not
93
+ # available and we fall back to plain `Kernel.warn`.
94
+ #
95
+ # When the warning is suppressed (either because `Warning[:deprecated]` is
96
+ # false or the runtime has otherwise muted the category), we deliberately
97
+ # leave the once-per-process flag unset so a future call with the category
98
+ # re-enabled still surfaces the notice.
99
+ def warn_timeout_deprecation
100
+ return if timeout.nil?
101
+ return if self.class.timeout_deprecation_warned
102
+
103
+ category_supported = deprecated_warning_category_supported?
104
+ return if category_supported && !deprecated_warnings_enabled?
105
+
106
+ self.class.timeout_deprecation_warned = true
107
+ if category_supported
108
+ Kernel.warn(TIMEOUT_DEPRECATION_MESSAGE, category: :deprecated)
109
+ else
110
+ Kernel.warn(TIMEOUT_DEPRECATION_MESSAGE)
111
+ end
112
+ end
113
+
114
+ def deprecated_warning_category_supported?
115
+ defined?(Warning) && Kernel.method(:warn).parameters.any? { |type, name| type == :key && name == :category }
116
+ end
117
+
118
+ def deprecated_warnings_enabled?
119
+ return true unless defined?(Warning) && Warning.respond_to?(:[])
120
+
121
+ Warning[:deprecated]
122
+ end
61
123
 
62
- validate_positive_integer(:tries, tries)
124
+ def validate_backoff_options
63
125
  validate_non_negative_number(:base_interval, base_interval)
64
126
  validate_non_negative_number(:multiplier, multiplier)
65
127
  validate_non_negative_number(:max_interval, max_interval)
66
128
  validate_rand_factor
67
129
  end
68
130
 
69
- private
131
+ def validate_unbounded_tries
132
+ if intervals
133
+ raise ArgumentError,
134
+ "intervals cannot be used with tries: Float::INFINITY"
135
+ end
136
+
137
+ unless finite_number?(max_elapsed_time)
138
+ raise ArgumentError,
139
+ "max_elapsed_time must be a finite number when tries is Float::INFINITY"
140
+ end
141
+
142
+ validate_non_negative_number(:max_elapsed_time, max_elapsed_time)
143
+ end
70
144
 
71
145
  def validate_intervals
72
146
  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.8.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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "stringio"
4
+
3
5
  describe Retriable::Config do
4
6
  let(:default_config) { described_class.new }
5
7
 
@@ -59,13 +61,123 @@ describe Retriable::Config do
59
61
 
60
62
  it "raises errors on invalid timing configuration" do
61
63
  expect { described_class.new(rand_factor: 1.1) }.to raise_error(ArgumentError, /rand_factor/)
62
- expect { described_class.new(timeout: -1) }.to raise_error(ArgumentError, /timeout/)
64
+ expect do
65
+ expect { described_class.new(timeout: -1) }.to raise_error(ArgumentError, /timeout/)
66
+ end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
67
+ end
68
+
69
+ context "timeout deprecation" do
70
+ it "warns when timeout is configured" do
71
+ expect do
72
+ described_class.new(timeout: 5)
73
+ end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
74
+ end
75
+
76
+ it "warns when timeout is set before validation" do
77
+ config = described_class.new
78
+ config.timeout = 5
79
+
80
+ expect do
81
+ config.validate!
82
+ end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
83
+ end
84
+
85
+ it "does not warn when timeout is nil" do
86
+ expect do
87
+ described_class.new(timeout: nil)
88
+ end.not_to output.to_stderr
89
+ end
90
+
91
+ it "does not warn when timeout is omitted" do
92
+ expect do
93
+ described_class.new
94
+ end.not_to output.to_stderr
95
+ end
96
+
97
+ it "warns at most once per process" do
98
+ original_stderr = $stderr
99
+ stderr = StringIO.new
100
+ begin
101
+ $stderr = stderr
102
+
103
+ described_class.new(timeout: 5)
104
+ described_class.new(timeout: 5)
105
+
106
+ config = described_class.new
107
+ config.timeout = 5
108
+ config.validate!
109
+ ensure
110
+ $stderr = original_stderr
111
+ end
112
+
113
+ expect(stderr.string.scan("timeout:` option is deprecated").size).to eq(1)
114
+ end
115
+
116
+ it "emits the warning under the :deprecated category when supported", if: WARN_CATEGORY_SUPPORTED do
117
+ captured = []
118
+ allow(Warning).to receive(:warn) do |message, category: nil|
119
+ captured << [message, category]
120
+ end
121
+
122
+ described_class.new(timeout: 5)
123
+
124
+ expect(captured.size).to eq(1)
125
+ message, category = captured.first
126
+ expect(message).to match(/timeout.*deprecated.*Retriable 4\.0/i)
127
+ expect(category).to eq(:deprecated)
128
+ end
129
+
130
+ it "is silenced by Warning[:deprecated] = false", if: WARN_CATEGORY_SUPPORTED do
131
+ original = Warning[:deprecated]
132
+ begin
133
+ Warning[:deprecated] = false
134
+ expect do
135
+ described_class.new(timeout: 5)
136
+ end.not_to output.to_stderr
137
+ ensure
138
+ Warning[:deprecated] = original
139
+ end
140
+ end
141
+
142
+ it "remains armed when silenced via Warning[:deprecated]", if: WARN_CATEGORY_SUPPORTED do
143
+ original = Warning[:deprecated]
144
+ begin
145
+ Warning[:deprecated] = false
146
+ described_class.new(timeout: 5)
147
+ ensure
148
+ Warning[:deprecated] = original
149
+ end
150
+
151
+ expect do
152
+ described_class.new(timeout: 5)
153
+ end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
154
+ end
63
155
  end
64
156
 
65
157
  it "raises errors when intervals is not an array" do
66
158
  expect { described_class.new(intervals: "1") }.to raise_error(ArgumentError, /intervals must be an Array/)
67
159
  end
68
160
 
161
+ it "requires a finite max_elapsed_time when tries is Float::INFINITY" do
162
+ expect { described_class.new(tries: Float::INFINITY, max_elapsed_time: nil) }
163
+ .to raise_error(ArgumentError, /max_elapsed_time must be a finite number/)
164
+ end
165
+
166
+ it "rejects intervals combined with tries: Float::INFINITY" do
167
+ expect do
168
+ described_class.new(
169
+ tries: Float::INFINITY,
170
+ max_elapsed_time: 60,
171
+ intervals: [0.1, 0.2],
172
+ )
173
+ end.to raise_error(ArgumentError, /intervals cannot be used with tries: Float::INFINITY/)
174
+ end
175
+
176
+ it "accepts tries: Float::INFINITY with a finite max_elapsed_time" do
177
+ expect { described_class.new(tries: Float::INFINITY, max_elapsed_time: 60) }
178
+ .not_to raise_error
179
+ end
180
+
69
181
  context "on: option validation" do
70
182
  it "accepts a single Exception subclass" do
71
183
  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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rbconfig"
4
+ require "stringio"
4
5
 
5
6
  describe Retriable do
6
7
  let(:time_table_handler) do
@@ -50,7 +51,9 @@ describe Retriable do
50
51
 
51
52
  it "raises a LocalJumpError if not given a block" do
52
53
  expect { described_class.retriable }.to raise_error(LocalJumpError)
53
- expect { described_class.retriable(timeout: 2) }.to raise_error(LocalJumpError)
54
+ expect do
55
+ expect { described_class.retriable(timeout: 2) }.to raise_error(LocalJumpError)
56
+ end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
54
57
  end
55
58
 
56
59
  it "stops at first try if the block does not raise an exception" do
@@ -84,8 +87,170 @@ describe Retriable do
84
87
  expect(@tries).to eq(10)
85
88
  end
86
89
 
90
+ it "does not prebuild generated intervals before the first successful try" do
91
+ interval_for = ->(_index) { raise "interval should not be used" }
92
+ backoff = instance_double(Retriable::ExponentialBackoff, interval_provider: interval_for)
93
+ allow(Retriable::ExponentialBackoff).to receive(:new).and_call_original
94
+ allow(Retriable::ExponentialBackoff).to receive(:new).with(
95
+ hash_including(:base_interval, :multiplier, :max_interval, :rand_factor),
96
+ ).and_return(backoff)
97
+
98
+ described_class.retriable(tries: 1_000_000) { increment_tries }
99
+
100
+ expect(@tries).to eq(1)
101
+ expect(backoff).to have_received(:interval_provider)
102
+ end
103
+
104
+ it "supports unbounded retries until the block succeeds" do
105
+ described_class.retriable(tries: Float::INFINITY, max_elapsed_time: 60) do
106
+ increment_tries
107
+ raise StandardError if @tries < 5
108
+ end
109
+
110
+ expect(@tries).to eq(5)
111
+ end
112
+
113
+ it "stops unbounded retries at max_elapsed_time" do
114
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
115
+ timeline = [
116
+ start_time,
117
+ start_time,
118
+ start_time,
119
+ start_time + 0.01,
120
+ start_time + 0.01,
121
+ start_time + 0.02,
122
+ start_time + 0.02,
123
+ ]
124
+ allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC) { timeline.shift || timeline.last }
125
+
126
+ expect do
127
+ described_class.retriable(
128
+ tries: Float::INFINITY,
129
+ base_interval: 0.01,
130
+ multiplier: 1.0,
131
+ rand_factor: 0.0,
132
+ sleep_disabled: true,
133
+ max_elapsed_time: 0.015,
134
+ ) do
135
+ increment_tries_with_exception
136
+ end
137
+ end.to raise_error(StandardError)
138
+
139
+ expect(@tries).to eq(3)
140
+ end
141
+
142
+ it "raises ArgumentError when tries is Float::INFINITY without a finite max_elapsed_time" do
143
+ expect do
144
+ described_class.retriable(tries: Float::INFINITY, max_elapsed_time: nil) { increment_tries }
145
+ end.to raise_error(ArgumentError, /max_elapsed_time must be a finite number/)
146
+ end
147
+
148
+ it "raises ArgumentError when tries is Float::INFINITY with infinite max_elapsed_time" do
149
+ expect do
150
+ described_class.retriable(tries: Float::INFINITY, max_elapsed_time: Float::INFINITY) { increment_tries }
151
+ end.to raise_error(ArgumentError, /max_elapsed_time must be a finite number/)
152
+ end
153
+
154
+ it "raises ArgumentError when tries is Float::INFINITY with custom intervals" do
155
+ expect do
156
+ described_class.retriable(tries: Float::INFINITY, intervals: [0.1, 0.2], max_elapsed_time: 60) do
157
+ increment_tries_with_exception
158
+ end
159
+ end.to raise_error(ArgumentError, /intervals cannot be used with tries: Float::INFINITY/)
160
+ end
161
+
162
+ it "raises ArgumentError when tries is Float::NAN" do
163
+ expect do
164
+ described_class.retriable(tries: Float::NAN) { increment_tries }
165
+ end.to raise_error(ArgumentError, /tries/)
166
+ end
167
+
168
+ it "raises ArgumentError when tries is negative infinity" do
169
+ expect do
170
+ described_class.retriable(tries: -Float::INFINITY) { increment_tries }
171
+ end.to raise_error(ArgumentError, /tries/)
172
+ end
173
+
87
174
  it "will timeout after 1 second" do
88
- expect { described_class.retriable(timeout: 1) { sleep(1.1) } }.to raise_error(Timeout::Error)
175
+ expect do
176
+ expect { described_class.retriable(timeout: 1) { sleep(1.1) } }.to raise_error(Timeout::Error)
177
+ end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
178
+ end
179
+
180
+ context "timeout: deprecation" do
181
+ it "warns at most once per process across repeated retriable calls" do
182
+ original_stderr = $stderr
183
+ stderr = StringIO.new
184
+ begin
185
+ $stderr = stderr
186
+
187
+ described_class.retriable(timeout: 5) { :noop }
188
+ described_class.retriable(timeout: 5) { :noop }
189
+ described_class.retriable(timeout: 5) { :noop }
190
+
191
+ expect(stderr.string.scan("timeout:` option is deprecated").size).to eq(1)
192
+ ensure
193
+ $stderr = original_stderr
194
+ end
195
+ end
196
+
197
+ it "warns when timeout is passed to retriable" do
198
+ expect do
199
+ described_class.retriable(timeout: 5) { :noop }
200
+ end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
201
+ end
202
+
203
+ it "keeps applying timeout while deprecated" do
204
+ original_stderr = $stderr
205
+ begin
206
+ $stderr = StringIO.new
207
+ expect do
208
+ described_class.retriable(timeout: 0.05, tries: 1) { sleep(0.5) }
209
+ end.to raise_error(Timeout::Error)
210
+ ensure
211
+ $stderr = original_stderr
212
+ end
213
+ end
214
+
215
+ it "warns when timeout is supplied through with_override" do
216
+ expect do
217
+ described_class.with_override(timeout: 5) do
218
+ described_class.retriable { :noop }
219
+ end
220
+ end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
221
+ end
222
+
223
+ it "warns when timeout is supplied through configure" do
224
+ original_config = described_class.config
225
+ begin
226
+ expect do
227
+ described_class.configure { |config| config.timeout = 5 }
228
+ described_class.retriable { :noop }
229
+ end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
230
+ ensure
231
+ described_class.configure do |config|
232
+ original_config.to_h.each { |key, value| config.public_send("#{key}=", value) }
233
+ end
234
+ end
235
+ end
236
+
237
+ it "is silenced by Warning[:deprecated] = false", if: WARN_CATEGORY_SUPPORTED do
238
+ original = Warning[:deprecated]
239
+ begin
240
+ Warning[:deprecated] = false
241
+ expect do
242
+ described_class.retriable(timeout: 5) { :noop }
243
+ end.not_to output.to_stderr
244
+ ensure
245
+ Warning[:deprecated] = original
246
+ end
247
+ end
248
+
249
+ it "does not warn when timeout is absent" do
250
+ expect do
251
+ described_class.retriable { :noop }
252
+ end.not_to output.to_stderr
253
+ end
89
254
  end
90
255
 
91
256
  it "applies a randomized exponential backoff to each try" do
data/spec/spec_helper.rb CHANGED
@@ -7,8 +7,21 @@ require "pry"
7
7
  require_relative "../lib/retriable"
8
8
  require_relative "support/exceptions"
9
9
 
10
+ # Make Retriable's deprecation notices observable to RSpec's
11
+ # `output().to_stderr` matcher. On Ruby 3.0+ the `:deprecated` warning category
12
+ # is suppressed by default, which would hide the notices we want to assert on.
13
+ WARNING_DEPRECATION_SUPPORTED = defined?(Warning) && Warning.respond_to?(:[])
14
+ Warning[:deprecated] = true if WARNING_DEPRECATION_SUPPORTED
15
+
16
+ # Used by deprecation specs that only make sense on Rubies where `Kernel#warn`
17
+ # supports the `category:` keyword (added in Ruby 2.7).
18
+ WARN_CATEGORY_SUPPORTED = WARNING_DEPRECATION_SUPPORTED &&
19
+ Kernel.method(:warn).parameters.include?(%i[key category])
20
+
10
21
  RSpec.configure do |config|
11
22
  config.before(:each) do
12
23
  srand(0)
24
+ Retriable::Config.timeout_deprecation_warned = false
25
+ Warning[:deprecated] = true if WARNING_DEPRECATION_SUPPORTED
13
26
  end
14
27
  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.8.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.