retriable 3.5.0 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cfd17d793faa48456456036eee71083bf629041cfe6f3f673e3bc92ff0090a88
4
- data.tar.gz: 27946e72249fce2362c2a374c8f352dd27bd8edd4ee3deea61c80e4d0da8f5cd
3
+ metadata.gz: 5961397b426eeaf2d2df8631f0a5dfe05adf52d5b9070ddbdf7b7825fd88f144
4
+ data.tar.gz: c982809ef09eb85ddedfcee95be53741846f6e4438af27e403c9ad4b020214c1
5
5
  SHA512:
6
- metadata.gz: 00424ca023aa864fd2a36016138d0dfd2a6a9030f64c7dd427bb296908b244847db139fcad208527f3bcb5cac075c5e0545fd5dd994f4fef48a31ef55baf2939
7
- data.tar.gz: a414ecfe2931a0bf3fb56f831d654c497a5a1b4d37e4dbe1acf1cbd646083f15bbd85271afcf8f5b9b9f6c484c9f4c5b34ef462b35b2ffd7ec4371b50e7769ab
6
+ metadata.gz: 30d8bfeeff407ab15fa9be7c2af2993142dce86d8406318e220afdb111c728b0c8c7cd2d6b105312ed9623074a5b20c88a9c19a6c33c3169d673d2276e0b52c4
7
+ data.tar.gz: f333fbf26eff94629f85f5d58688245025be3f39830bcad59da7f42ab52ad1506b155c03e1d96020a728b489ed016cb8ee1fcd5cde0a7a0ce3cfe59ad078c4d8
@@ -14,26 +14,22 @@ 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",
32
30
  "4.0",
33
31
  jruby,
34
32
  ]
35
- env:
36
- CC_TEST_REPORTER_ID: 20a1139ef1830b4f813a10a03d90e8aa179b5226f75e75c5a949b25756ebf558
37
33
 
38
34
  steps:
39
35
  # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
@@ -50,3 +46,36 @@ jobs:
50
46
 
51
47
  - name: Run rspec
52
48
  run: bundle exec rspec
49
+
50
+ lint:
51
+ runs-on: ubuntu-24.04
52
+
53
+ steps:
54
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
55
+
56
+ - name: Setup ruby
57
+ uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1
58
+ with:
59
+ ruby-version: "3.3"
60
+ bundler-cache: true
61
+
62
+ - name: Run rubocop
63
+ run: bundle exec rubocop
64
+
65
+ - name: Validate RBS
66
+ run: bundle exec rbs -I sig validate
67
+
68
+ audit:
69
+ runs-on: ubuntu-24.04
70
+
71
+ steps:
72
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
73
+
74
+ - name: Setup ruby
75
+ uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1
76
+ with:
77
+ ruby-version: "3.3"
78
+ bundler-cache: true
79
+
80
+ - name: Run bundler-audit
81
+ run: bundle exec bundle-audit check --update
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,77 @@
1
1
  # HEAD
2
2
 
3
+ ## 4.1.1
4
+
5
+ ### Bug fixes
6
+
7
+ - `retry_if`, `on_retry`, and `on_give_up` are now validated to be callable
8
+ (respond to `#call`) or falsy. A non-callable truthy value raises
9
+ `ArgumentError` at configuration time instead of a later `NoMethodError` on a
10
+ retry path. ([#140](https://github.com/kamui/retriable/pull/140))
11
+
12
+ ### Internal
13
+
14
+ - Add RBS type signatures for the public API (`Retriable.configure`, `config`,
15
+ `retriable`, `with_override`, `with_context`, and `Retriable::Config`) and
16
+ validate them in CI with `rbs validate`.
17
+ ([#142](https://github.com/kamui/retriable/pull/142))
18
+ - Enforce a minimum test coverage floor and add a `bundler-audit` dependency
19
+ audit job to CI. ([#143](https://github.com/kamui/retriable/pull/143))
20
+ - Remove an unused `CC_TEST_REPORTER_ID` from the CI workflow.
21
+ ([#141](https://github.com/kamui/retriable/pull/141))
22
+
23
+ ## 4.1.0
24
+
25
+ ### Bug fixes
26
+
27
+ - A per-call or `with_context` `tries:` now clears an inherited `intervals:` from
28
+ global config or a context, matching the documented precedence. Previously
29
+ `Retriable.retriable(tries: 1)` was silently ignored when `intervals` was
30
+ configured, running `intervals.size + 1` times. Passing both `intervals:` and
31
+ `tries:` in the same call still lets `intervals:` win.
32
+
33
+ ## 4.0.0
34
+
35
+ **This is a major release with breaking changes. Please read carefully before upgrading.**
36
+
37
+ ### Breaking changes
38
+
39
+ - 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.
40
+ - 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.
41
+
42
+ ### Features
43
+
44
+ - 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.
45
+ - 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`.
46
+
47
+ ### Internal
48
+
49
+ - Switched `Retriable.retriable`, `Retriable.with_context`, and the `Kernel` extension methods to Ruby 3.1+ anonymous block forwarding. No user-visible behavior change.
50
+
51
+ ## 3.8.0
52
+
53
+ ### Deprecations
54
+
55
+ - 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.
56
+
57
+ ## 3.7.0
58
+
59
+ - 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!`.
60
+
61
+ ## 3.6.1
62
+
63
+ - 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.
64
+ - Fix: Validate `with_override(contexts:)` shape before applying overrides. `contexts` may be `nil` or a hash, and each per-context override must be a hash.
65
+ - Docs: Document that `on_retry: false` disables a callback set in `Retriable.configure` for a single call.
66
+
67
+ ## 3.6.0
68
+
69
+ - Breaking: `Retriable.override` and `Retriable.reset_override` are removed and replaced by block-scoped `Retriable.with_override(opts) { ... }`. The new API requires a block, restores the previous override (or absence of override) when the block exits via `ensure`, and is thread-local — overrides set in one thread do not affect other threads, and child threads do not inherit them. Fibers within a thread still share the thread's active override. Nested `with_override` calls correctly restore the outer override on inner exit. See the README and `docs/testing.md` for migration and testing patterns. This replaces the override API introduced in 3.5.0.
70
+
71
+ ## 3.5.1
72
+
73
+ - Fix: Validate retry timing and count options before use to reject invalid retry configurations. `tries` must now be a positive integer unless a custom `intervals` array is provided.
74
+
3
75
  ## 3.5.0
4
76
 
5
77
  - Fix: Do not count skipped sleep intervals against `max_elapsed_time` when `sleep_disabled` is true.
data/Gemfile CHANGED
@@ -10,7 +10,10 @@ group :test do
10
10
  end
11
11
 
12
12
  group :development do
13
- gem "rubocop"
13
+ gem "bundler-audit", "~> 0.9"
14
+ gem "listen", "~> 3.1"
15
+ gem "rbs", "~> 3.0", platforms: :ruby
16
+ gem "rubocop", "~> 1.86"
14
17
  end
15
18
 
16
19
  group :development, :test do
data/README.md CHANGED
@@ -1,19 +1,78 @@
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
 
7
+ ## Table of Contents
8
+
9
+ - [Requirements](#requirements)
10
+ - [Migration from 3.x to 4.0](#migration-from-3x-to-40)
11
+ - [Installation](#installation)
12
+ - [Usage](#usage)
13
+ - [Defaults](#defaults)
14
+ - [Options](#options)
15
+ - [Configuring Which Options to Retry With :on](#configuring-which-options-to-retry-with-on)
16
+ - [Advanced Retry Matching With :retry_if](#advanced-retry-matching-with-retry_if)
17
+ - [Configuration](#configuration)
18
+ - [Override](#override)
19
+ - [Example Usage](#example-usage)
20
+ - [Custom Interval Array](#custom-interval-array)
21
+ - [Unbounded Retries (Opt-in)](#unbounded-retries-opt-in)
22
+ - [Turn off Exponential Backoff](#turn-off-exponential-backoff)
23
+ - [Callbacks](#callbacks)
24
+ - [Disabling a Configured Callback Per Call](#disabling-a-configured-callback-per-call)
25
+ - [Ensure/Else](#ensureelse)
26
+ - [Contexts](#contexts)
27
+ - [Kernel Extension](#kernel-extension)
28
+ - [Testing](#testing)
29
+ - [Credits](#credits)
30
+ - [Development](#development)
31
+ - [Running Specs](#running-specs)
32
+
8
33
  ## Requirements
9
34
 
10
- Ruby 2.3.0+
35
+ Ruby 3.2+
36
+
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.
38
+
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.
40
+
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:
11
56
 
12
- 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.
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`.
13
58
 
14
- 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.
59
+ 2. **Manage the timeout yourself inside the block** if no native option exists:
15
60
 
16
- 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.
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`.
17
76
 
18
77
  ## Installation
19
78
 
@@ -32,7 +91,7 @@ require 'retriable'
32
91
  In your Gemfile:
33
92
 
34
93
  ```ruby
35
- gem 'retriable', '~> 3.5'
94
+ gem 'retriable', '~> 4.0'
36
95
  ```
37
96
 
38
97
  ## Usage
@@ -79,27 +138,29 @@ The default interval table with 10 tries looks like this (in seconds, rounded to
79
138
 
80
139
  Here are the available options, in some vague order of relevance to most common use patterns:
81
140
 
82
- | Option | Default | Definition |
83
- | ---------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
84
- | **`tries`** | `3` | Number of attempts to make at running your code block (includes initial attempt). |
85
- | **`on`** | `[StandardError]` | Type of exceptions to retry. [Read more](#configuring-which-options-to-retry-with-on). |
86
- | **`retry_if`** | `nil` | Callable (for example a `Proc` or lambda) that receives the rescued exception and returns true/false to decide whether to retry. [Read more](#advanced-retry-matching-with-retry_if). |
87
- | **`on_retry`** | `nil` | `Proc` to call after each try is rescued. [Read more](#callbacks). |
88
- | **`sleep_disabled`** | `false` | When true, disable exponential backoff and attempt retries immediately. |
89
- | **`base_interval`** | `0.5` | The initial interval in seconds between tries. |
90
- | **`max_elapsed_time`** | `900` (15 min) | The maximum amount of total time in seconds that code is allowed to keep being retried. Set to `nil` to disable the time limit and retry based solely on `tries`. |
91
- | **`max_interval`** | `60` | The maximum interval in seconds that any individual retry can reach. |
92
- | **`multiplier`** | `1.5` | Each successive interval grows by this factor. A multipler of 1.5 means the next interval will be 1.5x the current interval. |
93
- | **`rand_factor`** | `0.5` | The percentage to randomize the next retry interval time. The next interval calculation is `randomized_interval = retry_interval * (random value in range [1 - randomization_factor, 1 + randomization_factor])` |
94
- | **`intervals`** | `nil` | Skip generated intervals and provide your own array of intervals in seconds. [Read more](#custom-interval-array). |
95
- | **`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. |
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`.
96
157
 
97
158
  #### Configuring Which Options to Retry With :on
98
159
 
99
160
  **`:on`** Can take the form:
100
161
 
101
162
  - An `Exception` class (retry every exception of this type, including subclasses)
102
- - 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)
103
164
  - A `Hash` where the keys are `Exception` classes and the values are one of:
104
165
  - `nil` (retry every exception of the key's type, including subclasses)
105
166
  - A single `Regexp` pattern (retries exceptions ONLY if their `message` matches the pattern)
@@ -145,39 +206,61 @@ end
145
206
  `#configure` sets defaults only. Per-call options passed to `Retriable.retriable` and
146
207
  `Retriable.with_context` still take precedence.
147
208
 
209
+ When a higher-precedence layer sets `tries:` without `intervals:`, it clears any
210
+ `intervals:` inherited from a lower layer (so `retriable(tries: 1)` runs once even
211
+ if `intervals` was configured). Within a single call, passing `intervals:` still
212
+ overrides `tries:`.
213
+
148
214
  ### Override
149
215
 
150
- If you need to force values globally (including over per-call options), use
151
- `#override`:
216
+ `#with_override` is a block-scoped API for forcing retry options that should
217
+ take precedence over both `#configure` defaults and per-call options. It is
218
+ primarily intended for tests — it lets a test force values like `tries: 1` or
219
+ `base_interval: 0` so the suite runs quickly and predictably, regardless of
220
+ the application's `#configure` defaults. In application code, prefer
221
+ `#configure` for app-level defaults and per-call options for caller-specific
222
+ values.
152
223
 
153
224
  ```ruby
154
- Retriable.override(tries: 1, base_interval: 0)
225
+ Retriable.with_override(tries: 1, base_interval: 0) do
226
+ Retriable.retriable do
227
+ # code here...
228
+ end
229
+ end
155
230
  ```
156
231
 
157
- `#override` precedence:
232
+ Precedence inside the block:
158
233
 
159
234
  ```
160
- override > local options > configure defaults
235
+ with_override > local options > configure defaults
161
236
  ```
162
237
 
163
- `#override` uses process-global state. Once set, it affects every caller and
164
- thread until `#reset_override` runs. Prefer setting it once at boot (or in test
165
- helpers), and avoid toggling it per request in multi-threaded runtimes.
238
+ `#with_override` requires a block and raises `ArgumentError` if called without
239
+ one. The override is active only while the block is executing, and is
240
+ automatically restored to its previous value when the block returns or raises.
241
+ Nested `#with_override` calls work as expected: the inner block temporarily
242
+ replaces the active override and the outer override is restored when the
243
+ inner block exits.
166
244
 
167
- `#override` stores the provided options directly. Do not mutate the options hash
168
- or nested values after passing them to `#override`.
245
+ `#with_override` is scoped to the **current thread**. The active override
246
+ does not affect any other thread, and child threads spawned inside the block
247
+ do not inherit it. This makes `#with_override` safe to use in parallel test
248
+ runners. Fibers running inside the same thread share the thread's active
249
+ override.
169
250
 
170
- To clear an override:
251
+ `#with_override` stores the provided options directly. Do not mutate the
252
+ options hash or nested values for the duration of the block.
171
253
 
172
- ```ruby
173
- Retriable.reset_override
174
- ```
254
+ For test-integration patterns (RSpec `around`, helper methods, Minitest, etc.),
255
+ see [docs/testing.md](docs/testing.md).
175
256
 
176
257
  ### Example Usage
177
258
 
178
259
  This example will only retry on a `Timeout::Error`, retry 3 times and sleep for a full second before each try.
179
260
 
180
261
  ```ruby
262
+ require "timeout"
263
+
181
264
  Retriable.retriable(on: Timeout::Error, tries: 3, base_interval: 1) do
182
265
  # code here...
183
266
  end
@@ -186,6 +269,8 @@ end
186
269
  You can also specify multiple errors to retry on by passing an array of exceptions.
187
270
 
188
271
  ```ruby
272
+ require "timeout"
273
+
189
274
  Retriable.retriable(on: [Timeout::Error, Errno::ECONNRESET]) do
190
275
  # code here...
191
276
  end
@@ -203,35 +288,40 @@ Retriable.retriable(on: {
203
288
  end
204
289
  ```
205
290
 
206
- 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.
207
-
208
- 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.
291
+ If you need millisecond units of time for the sleep interval:
209
292
 
210
293
  ```ruby
211
- Retriable.retriable(timeout: 60) do
294
+ Retriable.retriable(base_interval: (200 / 1000.0)) do
212
295
  # code here...
213
296
  end
214
297
  ```
215
298
 
216
- If you need millisecond units of time for the sleep or the timeout:
299
+ ### Custom Interval Array
300
+
301
+ You can also bypass the built-in interval generation and provide your own array of intervals. Supplying your own intervals overrides the `tries`, `base_interval`, `max_interval`, `rand_factor`, and `multiplier` parameters.
217
302
 
218
303
  ```ruby
219
- Retriable.retriable(base_interval: (200 / 1000.0), timeout: (500 / 1000.0)) do
304
+ Retriable.retriable(intervals: [0.5, 1.0, 2.0, 2.5]) do
220
305
  # code here...
221
306
  end
222
307
  ```
223
308
 
224
- ### Custom Interval Array
309
+ This example makes 5 total attempts. If the first attempt fails, the 2nd attempt occurs 0.5 seconds later.
225
310
 
226
- You can also bypass the built-in interval generation and provide your own array of intervals. Supplying your own intervals overrides the `tries`, `base_interval`, `max_interval`, `rand_factor`, and `multiplier` parameters.
311
+ ### Unbounded Retries (Opt-in)
312
+
313
+ 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.
227
314
 
228
315
  ```ruby
229
- Retriable.retriable(intervals: [0.5, 1.0, 2.0, 2.5]) do
316
+ Retriable.retriable(tries: Float::INFINITY, max_elapsed_time: 300) do
230
317
  # code here...
231
318
  end
232
319
  ```
233
320
 
234
- This example makes 5 total attempts. If the first attempt fails, the 2nd attempt occurs 0.5 seconds later.
321
+ When `tries: Float::INFINITY` is set:
322
+
323
+ - `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.
324
+ - Custom `intervals:` cannot be combined with `Float::INFINITY` and raises `ArgumentError`. Use the exponential backoff settings (`base_interval`, `multiplier`, `max_interval`, `rand_factor`) instead.
235
325
 
236
326
  ### Turn off Exponential Backoff
237
327
 
@@ -277,6 +367,48 @@ Retriable.retriable(on_retry: do_this_on_each_retry) do
277
367
  end
278
368
  ```
279
369
 
370
+ #### Disabling a Configured Callback Per Call
371
+
372
+ 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`.
373
+
374
+ ```ruby
375
+ Retriable.configure do |c|
376
+ c.on_retry = ->(exception, try, elapsed_time, next_interval) { log(...) }
377
+ end
378
+
379
+ # Most calls use the configured callback.
380
+ Retriable.retriable do
381
+ # ...
382
+ end
383
+
384
+ # This specific call opts out of the configured callback.
385
+ Retriable.retriable(on_retry: false) do
386
+ # ...
387
+ end
388
+ ```
389
+
390
+ 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`.
391
+
392
+ ```ruby
393
+ do_this_when_retries_stop = Proc.new do |exception, try, elapsed_time, next_interval, reason|
394
+ log "#{exception.class}: '#{exception.message}' - gave up after #{try} tries because #{reason}."
395
+ end
396
+
397
+ Retriable.retriable(on_give_up: do_this_when_retries_stop) do
398
+ # code here...
399
+ end
400
+ ```
401
+
402
+ 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.
403
+
404
+ 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.
405
+
406
+ 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".
407
+
408
+ `: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.
409
+
410
+ 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.
411
+
280
412
  ### Ensure/Else
281
413
 
282
414
  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:
@@ -304,7 +436,8 @@ Retriable.configure do |c|
304
436
  c.contexts[:aws] = {
305
437
  tries: 3,
306
438
  base_interval: 5,
307
- on_retry: Proc.new { puts 'Curse you, AWS!' }
439
+ on_retry: Proc.new { puts 'Curse you, AWS!' },
440
+ on_give_up: Proc.new { |_e, _try, _elapsed, _interval, reason| puts "Gave up on AWS: #{reason}" }
308
441
  }
309
442
  c.contexts[:mysql] = {
310
443
  tries: 10,
@@ -367,72 +500,16 @@ retriable_with_context(:api) do
367
500
  end
368
501
  ```
369
502
 
370
- ## Short Circuiting Retriable While Testing Your App
371
-
372
- When you are running tests for your app it often takes a long time to retry blocks that fail. This is because Retriable will default to 3 tries with exponential backoff. Ideally your tests will run as quickly as possible.
503
+ ## Testing
373
504
 
374
- If you want to short-circuit retries in tests, including calls that pass local options, use `Retriable.override` and set `tries` to `1`.
375
-
376
- Under Rails, keep shared defaults in `Retriable.configure` and apply test-only overrides conditionally:
377
-
378
- ```ruby
379
- # config/initializers/retriable.rb
380
- Retriable.configure do |c|
381
- c.tries = 3
382
- c.base_interval = 0.5
383
- c.rand_factor = 0.5
384
- end
505
+ `Retriable.with_override` is designed to short-circuit retries in your test
506
+ suite so failing blocks do not slow tests down. The simplest pattern is an
507
+ RSpec `around(:each)` hook (or your test framework's equivalent) that wraps
508
+ every example in `with_override(tries: 1, base_interval: 0)`.
385
509
 
386
- if Rails.env.test?
387
- Retriable.override(tries: 1, base_interval: 0, rand_factor: 0)
388
- end
389
- ```
390
-
391
- If you need to run a specific test with normal retry behavior, call `Retriable.reset_override` for that example and then reapply your test override afterward.
392
-
393
- Alternately, if you are using RSpec, you could override the Retriable configuration in your `spec_helper`.
394
-
395
- ```ruby
396
- # spec/spec_helper.rb
397
- Retriable.override(tries: 1, base_interval: 0, rand_factor: 0)
398
- ```
399
-
400
- If you have defined contexts for your configuration, top-level override values (such as `tries: 1`) already take precedence over context-specific values. However, if you need to override context-specific options (for example, clearing a context's `:intervals` array or changing its `:on` exception list), pass `:contexts` to `Retriable.override`:
401
-
402
- For example assuming you have configured a `google_api` context:
403
-
404
- ```ruby
405
- # config/initializers/retriable.rb
406
- Retriable.configure do |c|
407
- c.contexts[:google_api] = {
408
- tries: 5,
409
- base_interval: 3,
410
- on: [
411
- Net::ReadTimeout,
412
- Signet::AuthorizationError,
413
- Errno::ECONNRESET,
414
- OpenSSL::SSL::SSLError
415
- ]
416
- }
417
- end
418
- ```
419
-
420
- Then in your test environment, you can override both top-level defaults and per-context options:
421
-
422
- ```ruby
423
- # Build context overrides from existing configured context keys
424
- context_overrides = {}
425
- Retriable.config.contexts.each_key do |key|
426
- context_overrides[key] = { tries: 1, base_interval: 0 }
427
- end
428
-
429
- Retriable.override(
430
- multiplier: 1.0,
431
- rand_factor: 0.0,
432
- base_interval: 0,
433
- contexts: context_overrides,
434
- )
435
- ```
510
+ For Rails integration, opting out of the override for specific tests, and
511
+ overriding configured contexts in tests, see
512
+ [docs/testing.md](docs/testing.md).
436
513
 
437
514
  ## Credits
438
515