retriable 3.7.0 → 4.0.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: a5f3739e08167965e6f60cc6104d78f46709c323c2b1a80a839afcd5049ba3ca
4
- data.tar.gz: f9d5f2fe821d7629ceb6954c0afd1a99f63d8b6c99cecc9575e9b5672489be31
3
+ metadata.gz: 392e6bc1b69a4c85c286f5df1b90c5991243cd7290cd3b41c028f253ad16dae4
4
+ data.tar.gz: f078e585b65160045cc71e4a9f8746b7b480645cf841ff13a7bbbec78fc9ffeb
5
5
  SHA512:
6
- metadata.gz: f5d91b6ffed2805e0da09e307da9cddf018647c1ff89e040da96c9bffd26b5929465694d251c33fc19927288568b2ada4a52f3c1c50b384423038c0eb578634a
7
- data.tar.gz: 1f1e5f0f352a34831f2e71eda177b88336a4d7571be1f4370d98a151e7d06b65468b7d05f02310087e322611149d751e8a271de2c6931bdc8efef9a5f1cb335f
6
+ metadata.gz: 01dc250d04fbc71c89d6af526ab061ec4439d382ec50872776859415df9bad2d1d4c8f2e0cfb53fc263343d72871ce691df5840721d33caf0ccaacee03bf9f6e
7
+ data.tar.gz: f1ae42a3015541313322371fd297f5f677d1a974747b48a524c25e1f676fc7adeb8d309975851198c69632fc124a34e4db5488486783a40beb8758ef12cf6c01
@@ -14,18 +14,16 @@ jobs:
14
14
  ci:
15
15
  # The type of runner that the job will run on
16
16
  runs-on: ${{ matrix.os }}
17
+ # Ruby 4.0 is still in preview. Treat its results as informational so a
18
+ # preview-only regression doesn't block merges. Drop this gate (or update
19
+ # the version literal) once Ruby 4.0 is released and we treat it as
20
+ # required.
21
+ continue-on-error: ${{ matrix.ruby == '4.0' }}
17
22
  strategy:
18
23
  matrix:
19
24
  os: [ubuntu-24.04]
20
25
  ruby:
21
26
  [
22
- "2.3",
23
- "2.4",
24
- "2.5",
25
- "2.6",
26
- "2.7",
27
- "3.0",
28
- "3.1",
29
27
  "3.2",
30
28
  "3.3",
31
29
  "3.4",
@@ -50,3 +48,18 @@ jobs:
50
48
 
51
49
  - name: Run rspec
52
50
  run: bundle exec rspec
51
+
52
+ lint:
53
+ runs-on: ubuntu-24.04
54
+
55
+ steps:
56
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
57
+
58
+ - name: Setup ruby
59
+ uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1
60
+ with:
61
+ ruby-version: "3.3"
62
+ bundler-cache: true
63
+
64
+ - name: Run rubocop
65
+ run: bundle exec rubocop
data/.hound.yml CHANGED
@@ -1,2 +1,2 @@
1
1
  ruby:
2
- config_file: .rubocop.yml
2
+ enabled: false
data/.rubocop.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  AllCops:
2
2
  NewCops: enable
3
- TargetRubyVersion: 2.3
3
+ TargetRubyVersion: 3.2
4
4
 
5
5
  Style/StringLiterals:
6
6
  EnforcedStyle: double_quotes
@@ -40,3 +40,6 @@ Metrics/AbcSize:
40
40
 
41
41
  Style/TrailingCommaInArrayLiteral:
42
42
  Enabled: false
43
+
44
+ Naming/MethodParameterName:
45
+ MinNameLength: 2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # HEAD
2
2
 
3
+ ## 4.0.0
4
+
5
+ **This is a major release with breaking changes. Please read carefully before upgrading.**
6
+
7
+ ### Breaking changes
8
+
9
+ - Removed `timeout:` option. The `timeout:` option has been removed from `Retriable.retriable`, `Retriable.configure`, and `Retriable.with_override`. It was a thin wrapper around Ruby's `Timeout.timeout`, which has well-documented safety issues: it interrupts execution at arbitrary lines and can corrupt internal state in libraries that are not interrupt-safe (mutexes, file handles, network sockets, allocator state). This was first raised against this gem in [#96](https://github.com/kamui/retriable/issues/96) in 2021; Retriable 3.8.0 deprecated the option, and 4.0 removes the footgun entirely. As a side effect, the historical bug where Retriable's own internal `Timeout::Error` was silently retried by default is no longer reachable, since Retriable no longer raises a timeout itself. User-raised `Timeout::Error` (for example, from a `Timeout.timeout` block you write inside the retried block) is still matched by the default `on: [StandardError]` because `Timeout::Error < RuntimeError < StandardError`. Passing `timeout:` to `Retriable.retriable` or `Retriable.with_override` now raises `ArgumentError`; setting `config.timeout` in `Retriable.configure` now raises `NoMethodError` because the configuration attribute has been removed. See the [4.0 migration section in the README](README.md#migration-from-3x-to-40) for replacement patterns.
10
+ - Minimum Ruby version is now 3.2. Support for Ruby 2.x, 3.0, and 3.1 has been dropped in Retriable 4.0. If you need Retriable on Ruby 2.3.0-3.1.x, the 3.8.x line (`~> 3.8`) remains available.
11
+
12
+ ### Features
13
+
14
+ - Add [`on_give_up`](README.md#callbacks) 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.
15
+ - Accept a [`Set` of `Exception` classes](README.md#configuring-which-options-to-retry-with-on) as the `on:` option, in addition to a single class, an `Array`, or a `Hash`.
16
+
17
+ ### Internal
18
+
19
+ - Switched `Retriable.retriable`, `Retriable.with_context`, and the `Kernel` extension methods to Ruby 3.1+ anonymous block forwarding. No user-visible behavior change.
20
+
21
+ ## 3.8.0
22
+
23
+ ### Deprecations
24
+
25
+ - 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.
26
+
3
27
  ## 3.7.0
4
28
 
5
29
  - 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!`.
data/Gemfile CHANGED
@@ -10,7 +10,8 @@ group :test do
10
10
  end
11
11
 
12
12
  group :development do
13
- gem "rubocop"
13
+ gem "listen", "~> 3.1"
14
+ gem "rubocop", "~> 1.86"
14
15
  end
15
16
 
16
17
  group :development, :test do
data/README.md CHANGED
@@ -1,13 +1,13 @@
1
1
  # Retriable
2
2
 
3
3
  ![Build Status](https://github.com/kamui/retriable/actions/workflows/main.yml/badge.svg)
4
- [![Reviewed by Hound](https://img.shields.io/badge/Reviewed_by-Hound-8E64B0.svg)](https://houndci.com)
5
4
 
6
5
  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
6
 
8
7
  ## Table of Contents
9
8
 
10
9
  - [Requirements](#requirements)
10
+ - [Migration from 3.x to 4.0](#migration-from-3x-to-40)
11
11
  - [Installation](#installation)
12
12
  - [Usage](#usage)
13
13
  - [Defaults](#defaults)
@@ -18,8 +18,10 @@ Retriable is a simple DSL to retry failed code blocks with randomized [exponenti
18
18
  - [Override](#override)
19
19
  - [Example Usage](#example-usage)
20
20
  - [Custom Interval Array](#custom-interval-array)
21
+ - [Unbounded Retries (Opt-in)](#unbounded-retries-opt-in)
21
22
  - [Turn off Exponential Backoff](#turn-off-exponential-backoff)
22
23
  - [Callbacks](#callbacks)
24
+ - [Disabling a Configured Callback Per Call](#disabling-a-configured-callback-per-call)
23
25
  - [Ensure/Else](#ensureelse)
24
26
  - [Contexts](#contexts)
25
27
  - [Kernel Extension](#kernel-extension)
@@ -30,13 +32,47 @@ Retriable is a simple DSL to retry failed code blocks with randomized [exponenti
30
32
 
31
33
  ## Requirements
32
34
 
33
- Ruby 2.3.0+
35
+ Ruby 3.2+
34
36
 
35
- If you need ruby 2.0.0-2.2.x support, use the [3.1 branch](https://github.com/kamui/retriable/tree/3.1.x) by specifying `~3.1` in your Gemfile.
37
+ If you need Ruby 2.3.0-3.1.x support, use the [3.8.x branch](https://github.com/kamui/retriable/tree/3.8.x) by specifying `~> 3.8` in your Gemfile.
36
38
 
37
- If you need ruby 1.9.3 support, use the [2.x branch](https://github.com/kamui/retriable/tree/2.x) by specifying `~2.1` in your Gemfile.
39
+ If you need Ruby 2.0.0-2.2.x support, use the [3.1 branch](https://github.com/kamui/retriable/tree/3.1.x) by specifying `~3.1` in your Gemfile.
38
40
 
39
- If you need ruby 1.8.x to 1.9.2 support, use the [1.x branch](https://github.com/kamui/retriable/tree/1.x) by specifying `~1.4` in your Gemfile.
41
+ If you need Ruby 1.9.3 support, use the [2.x branch](https://github.com/kamui/retriable/tree/2.x) by specifying `~2.1` in your Gemfile.
42
+
43
+ If you need Ruby 1.8.x to 1.9.2 support, use the [1.x branch](https://github.com/kamui/retriable/tree/1.x) by specifying `~1.4` in your Gemfile.
44
+
45
+ ## Migration from 3.x to 4.0
46
+
47
+ ### Ruby version
48
+
49
+ Retriable 4.0 requires Ruby 3.2 or later. If you run Ruby 2.3.0-3.1.x, or want to stay on the 3.x gem line, use Retriable 3.8.x by specifying `~> 3.8` in your Gemfile.
50
+
51
+ ### `timeout:` option removed
52
+
53
+ The `timeout:` option was deprecated in Retriable 3.8.0 and has been removed in Retriable 4.0. It was a thin wrapper around `Timeout.timeout`, which has well-documented safety issues: it interrupts execution at arbitrary lines and can corrupt internal state in libraries that are not interrupt-safe. See [issue #96](https://github.com/kamui/retriable/issues/96) for the original report of this problem.
54
+
55
+ If you previously used `Retriable.retriable(timeout: 5) { ... }`, you have two recommended alternatives:
56
+
57
+ 1. **Use your library's native timeout** (preferred). For example, configure `Net::HTTP#read_timeout`, Faraday's `request.timeout`, or your database client's statement timeout. Library-native timeouts do not have the safety issues of `Timeout.timeout`.
58
+
59
+ 2. **Manage the timeout yourself inside the block** if no native option exists:
60
+
61
+ ```ruby
62
+ require "timeout"
63
+
64
+ Retriable.retriable do
65
+ Timeout.timeout(5) do
66
+ # code here...
67
+ end
68
+ end
69
+ ```
70
+
71
+ **Note:** This still uses `Timeout.timeout`, which has the same safety issues that motivated removing the option — interruption can happen at any line, including inside non-interrupt-safe library code (mutexes, file handles, network sockets, allocator state). Prefer option 1 wherever possible. For background, see [why Ruby's `Timeout` is dangerous](https://jvns.ca/blog/2015/11/27/why-rubys-timeout-is-dangerous-and-thread-dot-raise-is-terrifying/), [Headius on Thread#raise and Timeout](http://blog.headius.com/2008/02/ruby-threadraise-threadkill-timeoutrb.html), [In Ruby, don't use `Timeout`](https://adamhooper.medium.com/in-ruby-dont-use-timeout-77d9d4e5a001), and [Timeout: Ruby's most dangerous API](https://www.mikeperham.com/2015/05/08/timeout-rubys-most-dangerous-api/).
72
+
73
+ Like the removed `timeout:` option, `Timeout.timeout(5)` inside the block is per-try — each retry gets a fresh 5-second budget. For an overall cap across all retries, use `max_elapsed_time:` instead.
74
+
75
+ Passing `timeout:` to `Retriable.retriable` or `Retriable.with_override` now raises `ArgumentError`. The `timeout` configuration attribute has also been removed, so `Retriable.configure { |c| c.timeout = 5 }` now raises `NoMethodError`.
40
76
 
41
77
  ## Installation
42
78
 
@@ -55,7 +91,7 @@ require 'retriable'
55
91
  In your Gemfile:
56
92
 
57
93
  ```ruby
58
- gem 'retriable', '~> 3.6'
94
+ gem 'retriable', '~> 4.0'
59
95
  ```
60
96
 
61
97
  ## Usage
@@ -102,29 +138,29 @@ The default interval table with 10 tries looks like this (in seconds, rounded to
102
138
 
103
139
  Here are the available options, in some vague order of relevance to most common use patterns:
104
140
 
105
- | Option | Default | Definition |
106
- | ---------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
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
- | **`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, 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`.
141
+ | Option | Default | Definition |
142
+ | ---------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
143
+ | **`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. |
144
+ | **`on`** | `[StandardError]` | Type of exceptions to retry. [Read more](#configuring-which-options-to-retry-with-on). |
145
+ | **`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). |
146
+ | **`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). |
147
+ | **`on_give_up`** | `nil` | `Proc` to call when Retriable stops retrying after a rescued retriable exception. [Read more](#callbacks). |
148
+ | **`sleep_disabled`** | `false` | When true, disable exponential backoff and attempt retries immediately. |
149
+ | **`base_interval`** | `0.5` | The initial interval in seconds between tries. |
150
+ | **`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`. |
151
+ | **`max_interval`** | `60` | The maximum interval in seconds that any individual retry can reach. |
152
+ | **`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. |
153
+ | **`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])` |
154
+ | **`intervals`** | `nil` | Skip generated intervals and provide your own array of intervals in seconds. [Read more](#custom-interval-array). |
155
+
156
+ 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`, and `max_elapsed_time` must be non-negative numbers, with `max_elapsed_time` 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
157
 
122
158
  #### Configuring Which Options to Retry With :on
123
159
 
124
160
  **`:on`** Can take the form:
125
161
 
126
162
  - An `Exception` class (retry every exception of this type, including subclasses)
127
- - An `Array` of `Exception` classes (retry any exception of one of these types, including subclasses)
163
+ - An `Array` or `Set` of `Exception` classes (retry any exception of one of these types, including subclasses)
128
164
  - A `Hash` where the keys are `Exception` classes and the values are one of:
129
165
  - `nil` (retry every exception of the key's type, including subclasses)
130
166
  - A single `Regexp` pattern (retries exceptions ONLY if their `message` matches the pattern)
@@ -218,6 +254,8 @@ see [docs/testing.md](docs/testing.md).
218
254
  This example will only retry on a `Timeout::Error`, retry 3 times and sleep for a full second before each try.
219
255
 
220
256
  ```ruby
257
+ require "timeout"
258
+
221
259
  Retriable.retriable(on: Timeout::Error, tries: 3, base_interval: 1) do
222
260
  # code here...
223
261
  end
@@ -226,6 +264,8 @@ end
226
264
  You can also specify multiple errors to retry on by passing an array of exceptions.
227
265
 
228
266
  ```ruby
267
+ require "timeout"
268
+
229
269
  Retriable.retriable(on: [Timeout::Error, Errno::ECONNRESET]) do
230
270
  # code here...
231
271
  end
@@ -243,20 +283,10 @@ Retriable.retriable(on: {
243
283
  end
244
284
  ```
245
285
 
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
-
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
-
250
- ```ruby
251
- Retriable.retriable(timeout: 60) do
252
- # code here...
253
- end
254
- ```
255
-
256
- If you need millisecond units of time for the sleep or the timeout:
286
+ If you need millisecond units of time for the sleep interval:
257
287
 
258
288
  ```ruby
259
- Retriable.retriable(base_interval: (200 / 1000.0), timeout: (500 / 1000.0)) do
289
+ Retriable.retriable(base_interval: (200 / 1000.0)) do
260
290
  # code here...
261
291
  end
262
292
  ```
@@ -334,7 +364,7 @@ end
334
364
 
335
365
  #### Disabling a Configured Callback Per Call
336
366
 
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.
367
+ 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` or `on_retry: nil`.
338
368
 
339
369
  ```ruby
340
370
  Retriable.configure do |c|
@@ -352,6 +382,28 @@ Retriable.retriable(on_retry: false) do
352
382
  end
353
383
  ```
354
384
 
385
+ You can also use `:on_give_up` to run a callback when Retriable stops retrying after a rescued retriable exception. This callback receives the `exception`, the `try_number`, the `elapsed_time` for all tries so far, the `next_interval`, and the `reason` Retriable is giving up. The `reason` is either `:tries_exhausted` or `:max_elapsed_time`.
386
+
387
+ ```ruby
388
+ do_this_when_retries_stop = Proc.new do |exception, try, elapsed_time, next_interval, reason|
389
+ log "#{exception.class}: '#{exception.message}' - gave up after #{try} tries because #{reason}."
390
+ end
391
+
392
+ Retriable.retriable(on_give_up: do_this_when_retries_stop) do
393
+ # code here...
394
+ end
395
+ ```
396
+
397
+ When the reason is `:tries_exhausted`, `next_interval` is `nil` because there is no next retry. When the reason is `:max_elapsed_time`, `next_interval` is the interval that would have been slept before the next try. This reason means the next retry would exceed `max_elapsed_time`, not necessarily that the elapsed time has already exceeded it.
398
+
399
+ If both `:on_retry` and `:on_give_up` are configured, `:on_retry` still runs first for the final rescued retriable exception. This preserves the existing behavior that `:on_retry` runs whenever Retriable rescues an exception that matches its retry rules.
400
+
401
+ If you configure a default `:on_give_up` callback but want to suppress it for a specific call, pass `on_give_up: false` (or `nil`). Both are treated as "no callback".
402
+
403
+ `: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.
404
+
405
+ 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.
406
+
355
407
  ### Ensure/Else
356
408
 
357
409
  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:
@@ -379,7 +431,8 @@ Retriable.configure do |c|
379
431
  c.contexts[:aws] = {
380
432
  tries: 3,
381
433
  base_interval: 5,
382
- on_retry: Proc.new { puts 'Curse you, AWS!' }
434
+ on_retry: Proc.new { puts 'Curse you, AWS!' },
435
+ on_give_up: Proc.new { |_e, _try, _elapsed, _interval, reason| puts "Gave up on AWS: #{reason}" }
383
436
  }
384
437
  c.contexts[:mysql] = {
385
438
  tries: 10,
@@ -11,10 +11,10 @@ module Retriable
11
11
  sleep_disabled
12
12
  max_elapsed_time
13
13
  intervals
14
- timeout
15
14
  on
16
15
  retry_if
17
16
  on_retry
17
+ on_give_up
18
18
  contexts
19
19
  ]).freeze
20
20
 
@@ -31,10 +31,10 @@ module Retriable
31
31
  @sleep_disabled = false
32
32
  @max_elapsed_time = 900 # 15 min
33
33
  @intervals = nil
34
- @timeout = nil
35
34
  @on = [StandardError]
36
35
  @retry_if = nil
37
36
  @on_retry = nil
37
+ @on_give_up = nil
38
38
  @contexts = {}
39
39
 
40
40
  opts.each do |k, v|
@@ -47,13 +47,10 @@ module Retriable
47
47
  end
48
48
 
49
49
  def to_h
50
- ATTRIBUTES.each_with_object({}) do |key, hash|
51
- hash[key] = public_send(key)
52
- end
50
+ ATTRIBUTES.to_h { |key| [key, public_send(key)] }
53
51
  end
54
52
 
55
53
  def validate!
56
- validate_optional_non_negative_number(:timeout, timeout)
57
54
  validate_on(on)
58
55
  validate_intervals
59
56
  if unbounded_tries?(tries)
@@ -3,11 +3,11 @@
3
3
  require_relative "../../retriable"
4
4
 
5
5
  module Kernel
6
- def retriable(opts = {}, &block)
7
- Retriable.retriable(opts, &block)
6
+ def retriable(opts = {}, &)
7
+ Retriable.retriable(opts, &)
8
8
  end
9
9
 
10
- def retriable_with_context(context_key, opts = {}, &block)
11
- Retriable.with_context(context_key, opts, &block)
10
+ def retriable_with_context(context_key, opts = {}, &)
11
+ Retriable.with_context(context_key, opts, &)
12
12
  end
13
13
  end
@@ -46,7 +46,7 @@ module Retriable
46
46
 
47
47
  # Validates an `on:` value. Acceptable shapes:
48
48
  # - a Class that descends from Exception
49
- # - an Array whose elements are Classes that descend from Exception
49
+ # - an Array or Set whose elements are Classes that descend from Exception
50
50
  # - a Hash whose keys are such Classes and whose values are nil,
51
51
  # a Regexp, or an Array of Regexps
52
52
  #
@@ -58,12 +58,12 @@ module Retriable
58
58
  # Regexp values explicitly.
59
59
  def validate_on(value)
60
60
  case value
61
- when Hash
61
+ in Hash
62
62
  value.each do |klass, pattern|
63
63
  validate_on_class(klass)
64
64
  validate_on_hash_value(klass, pattern)
65
65
  end
66
- when Array
66
+ in Array | Set
67
67
  value.each { |klass| validate_on_class(klass) }
68
68
  else
69
69
  validate_on_class(value)
@@ -79,10 +79,7 @@ module Retriable
79
79
  def validate_on_hash_value(klass, pattern)
80
80
  return if pattern.nil?
81
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
82
+ return if pattern.is_a?(Array) && pattern.all?(Regexp)
86
83
 
87
84
  raise ArgumentError,
88
85
  "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.7.0"
4
+ VERSION = "4.0.0"
5
5
  end
data/lib/retriable.rb CHANGED
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "timeout"
4
3
  require_relative "retriable/config"
5
4
  require_relative "retriable/exponential_backoff"
6
5
  require_relative "retriable/version"
@@ -41,7 +40,7 @@ module Retriable
41
40
  end
42
41
  end
43
42
 
44
- def with_context(context_key, options = {}, &block)
43
+ def with_context(context_key, options = {}, &)
45
44
  contexts = available_contexts
46
45
 
47
46
  if !contexts.key?(context_key)
@@ -51,10 +50,10 @@ module Retriable
51
50
 
52
51
  return unless block_given?
53
52
 
54
- retriable(context_options_for(context_key, options), &block)
53
+ retriable(context_options_for(context_key, options), &)
55
54
  end
56
55
 
57
- def retriable(opts = {}, &block)
56
+ def retriable(opts = {}, &)
58
57
  override_config = current_override
59
58
  local_config = if opts.empty? && !override_config
60
59
  config
@@ -66,10 +65,10 @@ module Retriable
66
65
  local_config.validate!
67
66
 
68
67
  plan = retry_plan(local_config)
69
- timeout = local_config.timeout
70
68
  on = local_config.on
71
69
  retry_if = local_config.retry_if
72
70
  on_retry = local_config.on_retry
71
+ on_give_up = local_config.on_give_up
73
72
  sleep_disabled = local_config.sleep_disabled
74
73
  max_elapsed_time = local_config.max_elapsed_time
75
74
 
@@ -79,22 +78,22 @@ module Retriable
79
78
  elapsed_time = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time }
80
79
 
81
80
  execute_tries(
82
- max_tries: plan.max_tries, interval_for: plan.interval_for, timeout: timeout,
81
+ max_tries: plan.max_tries, interval_for: plan.interval_for,
83
82
  exception_list: exception_list, on: on, retry_if: retry_if, on_retry: on_retry,
84
- elapsed_time: elapsed_time, max_elapsed_time: max_elapsed_time,
85
- sleep_disabled: sleep_disabled, &block
83
+ on_give_up: on_give_up, elapsed_time: elapsed_time, max_elapsed_time: max_elapsed_time,
84
+ sleep_disabled: sleep_disabled, &
86
85
  )
87
86
  end
88
87
 
89
88
  def execute_tries( # rubocop:disable Metrics/ParameterLists
90
- max_tries:, interval_for:, timeout:, exception_list:,
91
- on:, retry_if:, on_retry:, elapsed_time:, max_elapsed_time:, sleep_disabled:, &block
89
+ max_tries:, interval_for:, exception_list:,
90
+ on:, retry_if:, on_retry:, on_give_up:, elapsed_time:, max_elapsed_time:, sleep_disabled:
92
91
  )
93
92
  try = 0
94
93
  loop do
95
94
  try += 1
96
95
  begin
97
- return call_with_timeout(timeout, try, &block)
96
+ return yield(try)
98
97
  rescue *exception_list => e
99
98
  raise unless retriable_exception?(e, on, exception_list, retry_if)
100
99
 
@@ -102,7 +101,13 @@ module Retriable
102
101
  call_on_retry(on_retry, e, try, elapsed_time.call, interval)
103
102
 
104
103
  elapsed_interval = sleep_disabled == true ? 0 : interval
105
- raise unless can_retry?(try, max_tries, elapsed_time.call, elapsed_interval, max_elapsed_time)
104
+ # Snapshot elapsed_time once so the stop check and on_give_up see the same value.
105
+ current_elapsed_time = elapsed_time.call
106
+ stop_reason = retry_stop_reason(try, max_tries, current_elapsed_time, elapsed_interval, max_elapsed_time)
107
+ if stop_reason
108
+ call_on_give_up(on_give_up, e, try, current_elapsed_time, interval, stop_reason)
109
+ raise
110
+ end
106
111
 
107
112
  sleep interval if sleep_disabled != true
108
113
  end
@@ -132,23 +137,30 @@ module Retriable
132
137
  ).interval_provider
133
138
  end
134
139
 
135
- def call_with_timeout(timeout, try)
136
- return Timeout.timeout(timeout) { yield(try) } if timeout
137
-
138
- yield(try)
139
- end
140
-
141
140
  def call_on_retry(on_retry, exception, try, elapsed_time, interval)
142
141
  return unless on_retry
143
142
 
144
143
  on_retry.call(exception, try, elapsed_time, interval)
145
144
  end
146
145
 
147
- def can_retry?(try, max_tries, elapsed_time, interval, max_elapsed_time)
148
- return false if max_tries && try >= max_tries
149
- return true if max_elapsed_time.nil?
146
+ def call_on_give_up( # rubocop:disable Metrics/ParameterLists
147
+ on_give_up, exception, try, elapsed_time, interval, reason
148
+ )
149
+ return unless on_give_up
150
+
151
+ on_give_up.call(exception, try, elapsed_time, interval, reason)
152
+ end
153
+
154
+ # `:tries_exhausted` is checked first, but the two conditions can't both hold
155
+ # on the same try in practice: `retry_plan` returns a nil interval whenever
156
+ # `try >= max_tries`, so `(elapsed_time + interval) > max_elapsed_time` is not
157
+ # evaluable on the exhausted-tries try. The early return guards against that
158
+ # nil and also pins precedence in case the plan ever changes.
159
+ def retry_stop_reason(try, max_tries, elapsed_time, interval, max_elapsed_time)
160
+ return :tries_exhausted if max_tries && try >= max_tries
161
+ return nil if max_elapsed_time.nil?
150
162
 
151
- (elapsed_time + interval) <= max_elapsed_time
163
+ :max_elapsed_time if (elapsed_time + interval) > max_elapsed_time
152
164
  end
153
165
 
154
166
  # When `on` is a Hash, we need to verify the exception matches a pattern.
@@ -244,9 +256,9 @@ module Retriable
244
256
  :execute_tries,
245
257
  :retry_plan,
246
258
  :interval_provider,
247
- :call_with_timeout,
248
259
  :call_on_retry,
249
- :can_retry?,
260
+ :call_on_give_up,
261
+ :retry_stop_reason,
250
262
  :retriable_exception?,
251
263
  :hash_exception_match?,
252
264
  :apply_override_options,
data/retriable.gemspec CHANGED
@@ -15,15 +15,10 @@ Gem::Specification.new do |spec|
15
15
  "APIs/services or file system calls."
16
16
  spec.homepage = "https://github.com/kamui/retriable"
17
17
  spec.license = "MIT"
18
+ spec.metadata["rubygems_mfa_required"] = "true"
18
19
 
19
20
  spec.files = `git ls-files -z`.split("\x0")
20
- spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
21
21
  spec.require_paths = ["lib"]
22
22
 
23
- spec.required_ruby_version = ">= 2.3.0"
24
-
25
- spec.add_development_dependency "bundler"
26
- spec.add_development_dependency "rspec", "~> 3"
27
-
28
- spec.add_development_dependency "listen", "~> 3.1"
23
+ spec.required_ruby_version = ">= 3.2"
29
24
  end
data/spec/config_spec.rb CHANGED
@@ -32,10 +32,6 @@ describe Retriable::Config do
32
32
  expect(default_config.intervals).to be_nil
33
33
  end
34
34
 
35
- it "timeout defaults to nil" do
36
- expect(default_config.timeout).to be_nil
37
- end
38
-
39
35
  it "on defaults to [StandardError]" do
40
36
  expect(default_config.on).to eq([StandardError])
41
37
  end
@@ -48,6 +44,10 @@ describe Retriable::Config do
48
44
  expect(default_config.on_retry).to be_nil
49
45
  end
50
46
 
47
+ it "on_give_up handler defaults to nil" do
48
+ expect(default_config.on_give_up).to be_nil
49
+ end
50
+
51
51
  it "contexts defaults to {}" do
52
52
  expect(default_config.contexts).to eq({})
53
53
  end
@@ -57,9 +57,12 @@ describe Retriable::Config do
57
57
  expect { described_class.new(does_not_exist: 123) }.to raise_error(ArgumentError, /not a valid option/)
58
58
  end
59
59
 
60
+ it "rejects timeout as an unknown option" do
61
+ expect { described_class.new(timeout: 5) }.to raise_error(ArgumentError, /not a valid option/)
62
+ end
63
+
60
64
  it "raises errors on invalid timing configuration" do
61
65
  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/)
63
66
  end
64
67
 
65
68
  it "raises errors when intervals is not an array" do
@@ -99,6 +102,15 @@ describe Retriable::Config do
99
102
  expect { described_class.new(on: [StandardError, RuntimeError]) }.not_to raise_error
100
103
  end
101
104
 
105
+ it "accepts a Set of Exception subclasses" do
106
+ expect { described_class.new(on: Set[StandardError, RuntimeError]) }.not_to raise_error
107
+ end
108
+
109
+ it "rejects a Set containing a non-Exception class" do
110
+ expect { described_class.new(on: Set[StandardError, Kernel]) }
111
+ .to raise_error(ArgumentError, /on must be an Exception class/)
112
+ end
113
+
102
114
  it "accepts a hash with nil pattern values" do
103
115
  expect { described_class.new(on: { StandardError => nil }) }.not_to raise_error
104
116
  end
@@ -38,6 +38,39 @@ describe Retriable do
38
38
  expect { retriable { increment_tries_with_exception } }.to raise_error(StandardError)
39
39
  expect(@tries).to eq(3)
40
40
  end
41
+
42
+ it "passes on_give_up through the kernel extension" do
43
+ require_relative "../lib/retriable/core_ext/kernel"
44
+ received_reason = nil
45
+ handler = proc { |_e, _try, _elapsed, _interval, reason| received_reason = reason }
46
+
47
+ expect { retriable(tries: 1, on_give_up: handler) { increment_tries_with_exception } }
48
+ .to raise_error(StandardError)
49
+
50
+ expect(received_reason).to eq(:tries_exhausted)
51
+ end
52
+
53
+ # These two specs lock in the anonymous block forwarding (`&`) semantics
54
+ # across both delegation layers: Kernel#retriable_with_context ->
55
+ # Retriable.with_context. If the `&` is dropped at either layer, the
56
+ # block is not forwarded and the inner `block_given?` check at
57
+ # lib/retriable.rb:51 short-circuits, causing the block to never run.
58
+ it "forwards a block through Kernel#retriable_with_context" do
59
+ require_relative "../lib/retriable/core_ext/kernel"
60
+ Retriable.configure { |c| c.contexts[:sql] = { tries: 1 } }
61
+
62
+ retriable_with_context(:sql) { increment_tries }
63
+
64
+ expect(@tries).to eq(1)
65
+ end
66
+
67
+ it "returns nil when Kernel#retriable_with_context is called without a block" do
68
+ require_relative "../lib/retriable/core_ext/kernel"
69
+ Retriable.configure { |c| c.contexts[:sql] = { tries: 1 } }
70
+
71
+ expect(retriable_with_context(:sql)).to be_nil
72
+ expect(@tries).to eq(0)
73
+ end
41
74
  end
42
75
 
43
76
  context "#retriable" do
@@ -50,7 +83,6 @@ describe Retriable do
50
83
 
51
84
  it "raises a LocalJumpError if not given a block" do
52
85
  expect { described_class.retriable }.to raise_error(LocalJumpError)
53
- expect { described_class.retriable(timeout: 2) }.to raise_error(LocalJumpError)
54
86
  end
55
87
 
56
88
  it "stops at first try if the block does not raise an exception" do
@@ -168,8 +200,8 @@ describe Retriable do
168
200
  end.to raise_error(ArgumentError, /tries/)
169
201
  end
170
202
 
171
- it "will timeout after 1 second" do
172
- expect { described_class.retriable(timeout: 1) { sleep(1.1) } }.to raise_error(Timeout::Error)
203
+ it "rejects timeout as an unknown option" do
204
+ expect { described_class.retriable(timeout: 1) { :noop } }.to raise_error(ArgumentError, /not a valid option/)
173
205
  end
174
206
 
175
207
  it "applies a randomized exponential backoff to each try" do
@@ -215,6 +247,187 @@ describe Retriable do
215
247
  end
216
248
  end
217
249
 
250
+ it "does not call on_retry when explicitly set to nil" do
251
+ callback_called = false
252
+ original_on_retry = described_class.config.on_retry
253
+
254
+ begin
255
+ described_class.configure do |c|
256
+ c.on_retry = proc { |_exception, _try, _elapsed_time, _next_interval| callback_called = true }
257
+ end
258
+
259
+ expect do
260
+ described_class.retriable(on_retry: nil, tries: 3) { increment_tries_with_exception }
261
+ end.to raise_error(StandardError)
262
+
263
+ expect(@tries).to eq(3)
264
+ expect(callback_called).to be(false)
265
+ ensure
266
+ described_class.configure do |c|
267
+ c.on_retry = original_on_retry
268
+ end
269
+ end
270
+ end
271
+
272
+ it "calls on_give_up with max elapsed time details before re-raising" do
273
+ described_class.configure { |c| c.sleep_disabled = false }
274
+ give_up_calls = []
275
+ on_give_up = proc do |exception, try, elapsed_time, next_interval, reason|
276
+ give_up_calls << [exception, try, elapsed_time, next_interval, reason]
277
+ end
278
+
279
+ expect do
280
+ described_class.retriable(
281
+ intervals: [1.0, 1.0],
282
+ max_elapsed_time: 0.5,
283
+ on_give_up: on_give_up,
284
+ ) do
285
+ increment_tries_with_exception
286
+ end
287
+ end.to raise_error(StandardError)
288
+
289
+ exception, try, elapsed_time, next_interval, reason = give_up_calls.fetch(0)
290
+ expect(give_up_calls.size).to eq(1)
291
+ expect(exception).to be_a(StandardError)
292
+ expect(exception.message).to eq("StandardError occurred")
293
+ expect(try).to eq(1)
294
+ expect(elapsed_time).to be >= 0
295
+ expect(next_interval).to eq(1.0)
296
+ expect(reason).to eq(:max_elapsed_time)
297
+ expect(@tries).to eq(1)
298
+ end
299
+
300
+ it "calls on_give_up with tries exhausted details before re-raising" do
301
+ give_up_calls = []
302
+ on_give_up = proc do |exception, try, elapsed_time, next_interval, reason|
303
+ give_up_calls << [exception, try, elapsed_time, next_interval, reason]
304
+ end
305
+
306
+ expect do
307
+ described_class.retriable(tries: 2, on_give_up: on_give_up) { increment_tries_with_exception }
308
+ end.to raise_error(StandardError)
309
+
310
+ exception, try, elapsed_time, next_interval, reason = give_up_calls.fetch(0)
311
+ expect(give_up_calls.size).to eq(1)
312
+ expect(exception).to be_a(StandardError)
313
+ expect(exception.message).to eq("StandardError occurred")
314
+ expect(try).to eq(2)
315
+ expect(elapsed_time).to be >= 0
316
+ expect(next_interval).to be_nil
317
+ expect(reason).to eq(:tries_exhausted)
318
+ expect(@tries).to eq(2)
319
+ end
320
+
321
+ it "does not call on_give_up when the block eventually succeeds" do
322
+ callback_called = false
323
+
324
+ described_class.retriable(tries: 3, on_give_up: proc { callback_called = true }) do
325
+ increment_tries
326
+ raise StandardError if @tries < 2
327
+ end
328
+
329
+ expect(callback_called).to be(false)
330
+ expect(@tries).to eq(2)
331
+ end
332
+
333
+ it "does not call on_give_up for non-retriable exception types" do
334
+ callback_called = false
335
+
336
+ expect do
337
+ described_class.retriable(on_give_up: proc { callback_called = true }) do
338
+ increment_tries_with_exception(NonStandardError)
339
+ end
340
+ end.to raise_error(NonStandardError)
341
+
342
+ expect(callback_called).to be(false)
343
+ expect(@tries).to eq(1)
344
+ end
345
+
346
+ it "does not call on_give_up when retry_if rejects the exception" do
347
+ callback_called = false
348
+
349
+ expect do
350
+ described_class.retriable(
351
+ tries: 3,
352
+ retry_if: ->(_exception) { false },
353
+ on_give_up: proc { callback_called = true },
354
+ ) do
355
+ increment_tries_with_exception
356
+ end
357
+ end.to raise_error(StandardError)
358
+
359
+ expect(callback_called).to be(false)
360
+ expect(@tries).to eq(1)
361
+ end
362
+
363
+ it "does not call on_give_up when explicitly set to false" do
364
+ callback_called = false
365
+ original_on_give_up = described_class.config.on_give_up
366
+
367
+ begin
368
+ described_class.configure do |c|
369
+ c.on_give_up = proc { callback_called = true }
370
+ end
371
+
372
+ expect do
373
+ described_class.retriable(on_give_up: false, tries: 1) { increment_tries_with_exception }
374
+ end.to raise_error(StandardError)
375
+
376
+ expect(callback_called).to be(false)
377
+ ensure
378
+ described_class.configure do |c|
379
+ c.on_give_up = original_on_give_up
380
+ end
381
+ end
382
+ end
383
+
384
+ it "does not call on_give_up when explicitly set to nil" do
385
+ callback_called = false
386
+ original_on_give_up = described_class.config.on_give_up
387
+
388
+ begin
389
+ described_class.configure do |c|
390
+ c.on_give_up = proc { callback_called = true }
391
+ end
392
+
393
+ expect do
394
+ described_class.retriable(on_give_up: nil, tries: 1) { increment_tries_with_exception }
395
+ end.to raise_error(StandardError)
396
+
397
+ expect(callback_called).to be(false)
398
+ ensure
399
+ described_class.configure do |c|
400
+ c.on_give_up = original_on_give_up
401
+ end
402
+ end
403
+ end
404
+
405
+ it "calls on_retry before on_give_up when giving up" do
406
+ events = []
407
+
408
+ expect do
409
+ described_class.retriable(
410
+ tries: 1,
411
+ on_retry: proc { events << :on_retry },
412
+ on_give_up: proc { events << :on_give_up },
413
+ ) do
414
+ increment_tries_with_exception
415
+ end
416
+ end.to raise_error(StandardError)
417
+
418
+ expect(events).to eq(%i[on_retry on_give_up])
419
+ end
420
+
421
+ it "propagates exceptions raised inside on_give_up, replacing the original exception" do
422
+ handler = proc { raise "handler exploded" }
423
+
424
+ expect do
425
+ described_class.retriable(tries: 1, on_give_up: handler) { increment_tries_with_exception }
426
+ end.to raise_error(RuntimeError, "handler exploded")
427
+
428
+ expect(@tries).to eq(1)
429
+ end
430
+
218
431
  context "with rand_factor 0.0 and an on_retry handler" do
219
432
  let(:tries) { 6 }
220
433
  let(:no_rand_timetable) { { 1 => 0.5, 2 => 0.75, 3 => 1.125 } }
@@ -294,6 +507,19 @@ describe Retriable do
294
507
  end
295
508
  end
296
509
 
510
+ context "with a Set :on parameter" do
511
+ it "retries each exception class in the Set" do
512
+ described_class.retriable(on: Set[StandardError, NonStandardError]) do
513
+ increment_tries
514
+
515
+ raise StandardError if @tries == 1
516
+ raise NonStandardError if @tries == 2
517
+ end
518
+
519
+ expect(@tries).to eq(3)
520
+ end
521
+ end
522
+
297
523
  context "with a hash :on parameter" do
298
524
  let(:on_hash) { { NonStandardError => /NonStandardError occurred/ } }
299
525
 
@@ -326,6 +552,20 @@ describe Retriable do
326
552
  expect(@tries).to eq(1)
327
553
  end
328
554
 
555
+ it "does not call on_give_up when exception class matches but message does not" do
556
+ callback_called = false
557
+
558
+ expect do
559
+ described_class.retriable(on: on_hash, on_give_up: proc { callback_called = true }) do
560
+ increment_tries
561
+ raise SecondNonStandardError, "not a match"
562
+ end
563
+ end.to raise_error(SecondNonStandardError, /not a match/)
564
+
565
+ expect(callback_called).to be(false)
566
+ expect(@tries).to eq(1)
567
+ end
568
+
329
569
  it "successfully retries when the values are arrays of exception message patterns" do
330
570
  exceptions = []
331
571
  handler = ->(exception, try, _elapsed_time, _next_interval) { exceptions[try] = exception }
@@ -656,17 +896,15 @@ describe Retriable do
656
896
  end
657
897
 
658
898
  it "treats non-hash configured contexts as empty when override contexts are hash" do
659
- begin
660
- described_class.configure { |c| c.contexts = nil }
661
-
662
- described_class.with_override(contexts: { api: { tries: 1 } }) do
663
- described_class.with_context(:api) { increment_tries }
664
- end
899
+ described_class.configure { |c| c.contexts = nil }
665
900
 
666
- expect(@tries).to eq(1)
667
- ensure
668
- described_class.configure { |c| c.contexts = {} }
901
+ described_class.with_override(contexts: { api: { tries: 1 } }) do
902
+ described_class.with_context(:api) { increment_tries }
669
903
  end
904
+
905
+ expect(@tries).to eq(1)
906
+ ensure
907
+ described_class.configure { |c| c.contexts = {} }
670
908
  end
671
909
 
672
910
  it "ignores nil override contexts values in with_context" do
@@ -936,6 +1174,31 @@ describe Retriable do
936
1174
 
937
1175
  expect(other_thread_tries).to eq(3)
938
1176
  end
1177
+
1178
+ it "applies overridden on_give_up handlers" do
1179
+ callback_called = false
1180
+
1181
+ expect do
1182
+ described_class.with_override(on_give_up: proc { callback_called = true }) do
1183
+ described_class.retriable(tries: 1) { increment_tries_with_exception }
1184
+ end
1185
+ end.to raise_error(StandardError)
1186
+
1187
+ expect(callback_called).to be(true)
1188
+ end
1189
+
1190
+ it "applies on_give_up handlers configured via per-context overrides" do
1191
+ received_reason = nil
1192
+ handler = proc { |_e, _try, _elapsed, _interval, reason| received_reason = reason }
1193
+
1194
+ expect do
1195
+ described_class.with_override(contexts: { api: { tries: 1, on_give_up: handler } }) do
1196
+ described_class.with_context(:api) { increment_tries_with_exception }
1197
+ end
1198
+ end.to raise_error(StandardError)
1199
+
1200
+ expect(received_reason).to eq(:tries_exhausted)
1201
+ end
939
1202
  end
940
1203
 
941
1204
  context "#with_context" do
@@ -994,5 +1257,17 @@ describe Retriable do
994
1257
  described_class.with_context(:broken) { increment_tries }
995
1258
  expect(@tries).to eq(1)
996
1259
  end
1260
+
1261
+ it "invokes on_give_up configured on a context" do
1262
+ callback_called = false
1263
+ described_class.configure do |c|
1264
+ c.contexts[:flaky] = { tries: 1, on_give_up: proc { callback_called = true } }
1265
+ end
1266
+
1267
+ expect { described_class.with_context(:flaky) { increment_tries_with_exception } }
1268
+ .to raise_error(StandardError)
1269
+
1270
+ expect(callback_called).to be(true)
1271
+ end
997
1272
  end
998
1273
  end
metadata CHANGED
@@ -1,56 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: retriable
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.7.0
4
+ version: 4.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jack Chu
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: bundler
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - ">="
17
- - !ruby/object:Gem::Version
18
- version: '0'
19
- type: :development
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - ">="
24
- - !ruby/object:Gem::Version
25
- version: '0'
26
- - !ruby/object:Gem::Dependency
27
- name: rspec
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - "~>"
31
- - !ruby/object:Gem::Version
32
- version: '3'
33
- type: :development
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - "~>"
38
- - !ruby/object:Gem::Version
39
- version: '3'
40
- - !ruby/object:Gem::Dependency
41
- name: listen
42
- requirement: !ruby/object:Gem::Requirement
43
- requirements:
44
- - - "~>"
45
- - !ruby/object:Gem::Version
46
- version: '3.1'
47
- type: :development
48
- prerelease: false
49
- version_requirements: !ruby/object:Gem::Requirement
50
- requirements:
51
- - - "~>"
52
- - !ruby/object:Gem::Version
53
- version: '3.1'
11
+ dependencies: []
54
12
  description: Retriable is a simple DSL to retry failed code blocks with randomized
55
13
  exponential backoff. This is especially useful when interacting with external APIs/services
56
14
  or file system calls.
@@ -91,7 +49,8 @@ files:
91
49
  homepage: https://github.com/kamui/retriable
92
50
  licenses:
93
51
  - MIT
94
- metadata: {}
52
+ metadata:
53
+ rubygems_mfa_required: 'true'
95
54
  rdoc_options: []
96
55
  require_paths:
97
56
  - lib
@@ -99,7 +58,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
99
58
  requirements:
100
59
  - - ">="
101
60
  - !ruby/object:Gem::Version
102
- version: 2.3.0
61
+ version: '3.2'
103
62
  required_rubygems_version: !ruby/object:Gem::Requirement
104
63
  requirements:
105
64
  - - ">="
@@ -110,9 +69,4 @@ rubygems_version: 4.0.3
110
69
  specification_version: 4
111
70
  summary: Retriable is a simple DSL to retry failed code blocks with randomized exponential
112
71
  backoff
113
- test_files:
114
- - spec/config_spec.rb
115
- - spec/exponential_backoff_spec.rb
116
- - spec/retriable_spec.rb
117
- - spec/spec_helper.rb
118
- - spec/support/exceptions.rb
72
+ test_files: []