retriable 3.8.0 → 5.0.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: 25948e6545b8d98b0c7b08416bdf23b017ced352eb2a4eb1f2e9fdac45912031
4
- data.tar.gz: 4a92afb17f79a4ad173b046158ca634b0728fc6ccceedc1b7789c9106342b513
3
+ metadata.gz: 7ec8418b3d0009ce1ddfd7aa839eab270ae479a2a9498660e9cc0da8fa2541c5
4
+ data.tar.gz: efccc4c3e6e29f5ba12426f1ec39503e9c59a9b606389a2c318606a205bb18b7
5
5
  SHA512:
6
- metadata.gz: e43731b305a64c806bf57c13d78da410412d8ea4adaec9f255bd434b2d190ac53b6fac29172b72ed6bcc7329a3d5378c4075d72f6dede791f1e0bd16f400e58e
7
- data.tar.gz: 4259468487738b9ae5bcc817ac992af3341f15bcfe604ab0a61ddf1f8793b444775aa57c06ce5f95561e179186e7360e86a877afd02f3b346b68377ffb3ecaf9
6
+ metadata.gz: b7a1f5935366d2b243e508f5da6308ebdd403e61b4d4c9dc05f7f3018c3d93a0bacca1e9a2c9c84a0ebdf0fe685116cd4e3a76bf8dbafeaf6edb25f03a4b31fe
7
+ data.tar.gz: b5bdcef731117fc3c379b65a54e8acfecc9403e188089d269c86f721e6f7bf080f4c2670dc20f15ce09941f69e5ee117a493d593ec94c076c300c86eb02de841
@@ -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/AGENTS.md ADDED
@@ -0,0 +1,15 @@
1
+ # Retriable
2
+
3
+ ## Agent skills
4
+
5
+ ### Issue tracker
6
+
7
+ Issues live in GitHub Issues on `kamui/retriable`, via the `gh` CLI. See `docs/agents/issue-tracker.md`.
8
+
9
+ ### Triage labels
10
+
11
+ The five canonical triage roles, using their default label strings. See `docs/agents/triage-labels.md`.
12
+
13
+ ### Domain docs
14
+
15
+ Single-context: `CONTEXT.md` and `docs/adr/` at the repo root. See `docs/agents/domain.md`.
data/CHANGELOG.md CHANGED
@@ -1,5 +1,164 @@
1
1
  # HEAD
2
2
 
3
+ ## 5.0.1
4
+
5
+ ### Bug fixes
6
+
7
+ - `Retriable.retriable` now propagates `StopIteration` instead of reporting it as
8
+ retry exhaustion. The retry loop used `Kernel#loop`, which rescues
9
+ `StopIteration`, so a block or callback (`on_retry`, `on_give_up`) raising it
10
+ was converted into a `Retriable::ExhaustedError` even when retries remained.
11
+ The loop no longer goes through `Kernel#loop`, so `StopIteration` reaches the
12
+ caller as any other exception would.
13
+ ([#155](https://github.com/kamui/retriable/pull/155))
14
+ - A reused configuration is now validated once, when it is picked up for an
15
+ attempt, instead of being validated again immediately before every use. The
16
+ validation guarantee is unchanged; this only removes the duplicate work on
17
+ each retry.
18
+ ([#156](https://github.com/kamui/retriable/pull/156))
19
+
20
+ ## 5.0.0
21
+
22
+ **This is a major release with a breaking change. Please read carefully before
23
+ upgrading.**
24
+
25
+ ### Breaking changes
26
+
27
+ Retriable 5.0 makes the thread-safety change below. Because it changes direct
28
+ config mutation, it is released as a major version.
29
+ ([#151](https://github.com/kamui/retriable/pull/151))
30
+
31
+ The published config is frozen. Mutating `Retriable.config` directly
32
+ raises `FrozenError`:
33
+
34
+ ```ruby
35
+ Retriable.config.sleep_disabled = true # => FrozenError
36
+ Retriable.config.contexts[:api] = {} # => FrozenError
37
+ ```
38
+
39
+ Go through `configure` instead:
40
+
41
+ ```ruby
42
+ Retriable.configure { |c| c.sleep_disabled = true }
43
+ ```
44
+
45
+ Check your test setup first: `Retriable.config.sleep_disabled = true` in a
46
+ `spec_helper` or `rails_helper` is the likeliest place this bites. Reading
47
+ `Retriable.config` is unaffected.
48
+
49
+ A published config is shared by every thread reading it, so an in-place write
50
+ was a data race that could corrupt another thread's retry behavior with no sign
51
+ anything had gone wrong. Freezing it is what makes the copy-on-write guarantee
52
+ below hold in practice rather than only on paper.
53
+
54
+ The full upgrade guide lives in [docs/migration.md](docs/migration.md#4x-to-5x).
55
+
56
+ ### Bug fixes
57
+
58
+ - `Retriable.configure` is now thread-safe. Configuration is copy-on-write: the
59
+ block mutates a duplicate, which is published atomically only if the block
60
+ returns without raising. Concurrent readers therefore see either the whole
61
+ previous config or the whole new one, never a half-applied mix, and a raising
62
+ block leaves the existing config in place. Nested mutable values (`on`,
63
+ `intervals`, `contexts`, and anything inside them) are deep-copied, so a
64
+ mutation inside a `configure` block can no longer reach back into the config
65
+ other threads are reading. `Retriable.with_context` now resolves the context
66
+ lookup and the global options against a single snapshot, closing a race where
67
+ a concurrent `configure` could drop a context's retry options. Nested
68
+ `configure` calls remain supported: they share the outer working copy and
69
+ publish once when the outermost block returns. See **Breaking changes** above
70
+ for the direct-mutation behavior change this required.
71
+ ([#151](https://github.com/kamui/retriable/pull/151))
72
+
73
+ ## 4.2.0
74
+
75
+ ### Bug fixes
76
+
77
+ - The `Kernel` extension methods (`require "retriable/core_ext/kernel"`) are now
78
+ private, matching idiomatic `Kernel` helpers like `puts` and `rand`.
79
+ Previously `retriable` and `retriable_with_context` were public instance
80
+ methods, so they leaked onto every object's public API and could be invoked
81
+ with an explicit receiver (e.g. `"foo".retriable { ... }`). They remain
82
+ callable in the documented receiver-less form.
83
+ ([#146](https://github.com/kamui/retriable/pull/146))
84
+ - `Retriable.with_context` (and `Kernel#retriable_with_context`) now raises
85
+ `ArgumentError` when called without a block, matching `with_override`.
86
+ Previously a missing block was silently ignored: the call returned `nil` and
87
+ the intended block never ran, hiding a caller bug. Behavior change: code that
88
+ relied on the silent no-op will now raise.
89
+ - `Config#validate!` now validates the structure of each entry in `contexts`,
90
+ so configured contexts are checked on every `Retriable.retriable`/
91
+ `with_context` call rather than only when a given context is first used. A
92
+ context whose options contain an unknown key (including a nested `contexts`
93
+ key) now raises `ArgumentError, "<key> is not a valid option"`, matching the
94
+ `with_override` path. Non-Hash `contexts` and non-Hash per-context values
95
+ remain leniently treated as empty options (no behavior change). Option
96
+ _values_ are still validated lazily at retry time, unchanged.
97
+
98
+ ### Docs
99
+
100
+ - Document that `on_retry` receives `next_interval: nil` on the final rescued
101
+ attempt, when Retriable is about to give up because `tries` are exhausted.
102
+ `on_retry` still fires before `on_give_up` (unchanged behavior); the `nil`
103
+ contract is now called out in the `on_retry` documentation so handlers guard
104
+ arithmetic or logging on `next_interval`.
105
+
106
+ ### Performance
107
+
108
+ - `Config#initialize` no longer allocates a throwaway `ExponentialBackoff` (and
109
+ runs its redundant `validate!`) just to read default values. Defaults now live
110
+ in a frozen `ExponentialBackoff::DEFAULTS` constant, removing an allocation and
111
+ redundant validation from the `retriable` hot path.
112
+ ([#149](https://github.com/kamui/retriable/pull/149))
113
+
114
+ ## 4.1.1
115
+
116
+ ### Bug fixes
117
+
118
+ - `retry_if`, `on_retry`, and `on_give_up` are now validated to be callable
119
+ (respond to `#call`) or falsy. A non-callable truthy value raises
120
+ `ArgumentError` at configuration time instead of a later `NoMethodError` on a
121
+ retry path. ([#140](https://github.com/kamui/retriable/pull/140))
122
+
123
+ ### Internal
124
+
125
+ - Add RBS type signatures for the public API (`Retriable.configure`, `config`,
126
+ `retriable`, `with_override`, `with_context`, and `Retriable::Config`) and
127
+ validate them in CI with `rbs validate`.
128
+ ([#142](https://github.com/kamui/retriable/pull/142))
129
+ - Enforce a minimum test coverage floor and add a `bundler-audit` dependency
130
+ audit job to CI. ([#143](https://github.com/kamui/retriable/pull/143))
131
+ - Remove an unused `CC_TEST_REPORTER_ID` from the CI workflow.
132
+ ([#141](https://github.com/kamui/retriable/pull/141))
133
+
134
+ ## 4.1.0
135
+
136
+ ### Bug fixes
137
+
138
+ - A per-call or `with_context` `tries:` now clears an inherited `intervals:` from
139
+ global config or a context, matching the documented precedence. Previously
140
+ `Retriable.retriable(tries: 1)` was silently ignored when `intervals` was
141
+ configured, running `intervals.size + 1` times. Passing both `intervals:` and
142
+ `tries:` in the same call still lets `intervals:` win.
143
+
144
+ ## 4.0.0
145
+
146
+ **This is a major release with breaking changes. Please read carefully before upgrading.**
147
+
148
+ ### Breaking changes
149
+
150
+ - 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 guide](docs/migration.md#3x-to-40) for replacement patterns.
151
+ - 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.
152
+
153
+ ### Features
154
+
155
+ - 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.
156
+ - 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`.
157
+
158
+ ### Internal
159
+
160
+ - Switched `Retriable.retriable`, `Retriable.with_context`, and the `Kernel` extension methods to Ruby 3.1+ anonymous block forwarding. No user-visible behavior change.
161
+
3
162
  ## 3.8.0
4
163
 
5
164
  ### Deprecations
data/Gemfile CHANGED
@@ -10,10 +10,15 @@ 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", "~> 4.0", platforms: :ruby
16
+ gem "rubocop", "~> 1.86"
14
17
  end
15
18
 
16
19
  group :development, :test do
20
+ # ostruct left the default gems in Ruby 3.5; pry still requires it.
21
+ gem "ostruct"
17
22
  gem "pry"
18
23
  gem "rake", "~> 13.0"
19
24
  end
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
+ - [Upgrading](#upgrading)
11
11
  - [Installation](#installation)
12
12
  - [Usage](#usage)
13
13
  - [Defaults](#defaults)
@@ -17,10 +17,11 @@ 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)
21
20
  - [Custom Interval Array](#custom-interval-array)
21
+ - [Unbounded Retries (Opt-in)](#unbounded-retries-opt-in)
22
22
  - [Turn off Exponential Backoff](#turn-off-exponential-backoff)
23
23
  - [Callbacks](#callbacks)
24
+ - [Disabling a Configured Callback Per Call](#disabling-a-configured-callback-per-call)
24
25
  - [Ensure/Else](#ensureelse)
25
26
  - [Contexts](#contexts)
26
27
  - [Kernel Extension](#kernel-extension)
@@ -31,13 +32,29 @@ Retriable is a simple DSL to retry failed code blocks with randomized [exponenti
31
32
 
32
33
  ## Requirements
33
34
 
34
- Ruby 2.3.0+
35
+ Ruby 3.2+
35
36
 
36
- 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.
37
38
 
38
- 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.
39
40
 
40
- 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
+ ## Upgrading
46
+
47
+ Retriable 5.0 changes one thing for existing users: `Retriable.config` is now a
48
+ frozen snapshot, so direct writes raise `FrozenError`.
49
+
50
+ ```ruby
51
+ Retriable.config.sleep_disabled = true # => FrozenError
52
+ Retriable.configure { |c| c.sleep_disabled = true } # do this instead
53
+ ```
54
+
55
+ Test setup files such as `spec_helper` and `rails_helper` are the likeliest
56
+ place this bites. For the full 4.x to 5.x and 3.x to 4.0 guides, see
57
+ [docs/migration.md](docs/migration.md).
41
58
 
42
59
  ## Installation
43
60
 
@@ -56,7 +73,7 @@ require 'retriable'
56
73
  In your Gemfile:
57
74
 
58
75
  ```ruby
59
- gem 'retriable', '~> 3.8'
76
+ gem 'retriable', '~> 5.0'
60
77
  ```
61
78
 
62
79
  ## Usage
@@ -103,29 +120,29 @@ The default interval table with 10 tries looks like this (in seconds, rounded to
103
120
 
104
121
  Here are the available options, in some vague order of relevance to most common use patterns:
105
122
 
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`.
123
+ | Option | Default | Definition |
124
+ | ---------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
125
+ | **`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. |
126
+ | **`on`** | `[StandardError]` | Type of exceptions to retry. [Read more](#configuring-which-options-to-retry-with-on). |
127
+ | **`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). |
128
+ | **`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). |
129
+ | **`on_give_up`** | `nil` | `Proc` to call when Retriable stops retrying after a rescued retriable exception. [Read more](#callbacks). |
130
+ | **`sleep_disabled`** | `false` | When true, disable exponential backoff and attempt retries immediately. |
131
+ | **`base_interval`** | `0.5` | The initial interval in seconds between tries. |
132
+ | **`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`. |
133
+ | **`max_interval`** | `60` | The maximum interval in seconds that any individual retry can reach. |
134
+ | **`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. |
135
+ | **`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])` |
136
+ | **`intervals`** | `nil` | Skip generated intervals and provide your own array of intervals in seconds. [Read more](#custom-interval-array). |
137
+
138
+ 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`.
122
139
 
123
140
  #### Configuring Which Options to Retry With :on
124
141
 
125
142
  **`:on`** Can take the form:
126
143
 
127
144
  - An `Exception` class (retry every exception of this type, including subclasses)
128
- - An `Array` of `Exception` classes (retry any exception of one of these types, including subclasses)
145
+ - An `Array` or `Set` of `Exception` classes (retry any exception of one of these types, including subclasses)
129
146
  - A `Hash` where the keys are `Exception` classes and the values are one of:
130
147
  - `nil` (retry every exception of the key's type, including subclasses)
131
148
  - A single `Regexp` pattern (retries exceptions ONLY if their `message` matches the pattern)
@@ -171,6 +188,55 @@ end
171
188
  `#configure` sets defaults only. Per-call options passed to `Retriable.retriable` and
172
189
  `Retriable.with_context` still take precedence.
173
190
 
191
+ When a higher-precedence layer sets `tries:` without `intervals:`, it clears any
192
+ `intervals:` inherited from a lower layer (so `retriable(tries: 1)` runs once even
193
+ if `intervals` was configured). Within a single call, passing `intervals:` still
194
+ overrides `tries:`.
195
+
196
+ #### Thread safety
197
+
198
+ `#configure` is the only supported way to change configuration, and it is safe to
199
+ call from multiple threads.
200
+
201
+ Configuration is copy-on-write. `#configure` duplicates the current config, hands
202
+ your block the copy, and publishes it only if the block returns without raising.
203
+ So a reader in another thread always sees either the whole previous config or the
204
+ whole new one, never a half-applied mix, and a block that raises leaves the
205
+ existing config in place.
206
+
207
+ Configuration blocks are serialized. Keep them short, and do not wait inside one
208
+ for work that may call `#configure`, because that work cannot begin until the
209
+ current block returns. Readers are unaffected and continue using the last
210
+ published config while a block runs.
211
+
212
+ The published config is deeply frozen. Reaching around `#configure` to mutate it
213
+ raises `FrozenError`:
214
+
215
+ ```ruby
216
+ Retriable.config.tries = 5 # => FrozenError
217
+ Retriable.config.contexts[:api] = {} # => FrozenError
218
+
219
+ Retriable.configure { |c| c.tries = 5 } # this is the supported path
220
+ ```
221
+
222
+ That is deliberate. A published config is shared by every thread reading it, so an
223
+ in-place write is a data race that used to corrupt other threads' retry behavior
224
+ silently. `Retriable.config` remains fine to **read**.
225
+
226
+ Two more details:
227
+
228
+ - `#configure` calls can nest. Nested calls on the configuring thread, including
229
+ calls from its fibers, share the outer working copy. Only the outermost call
230
+ publishes. If its block raises, none of the nested changes are published. A
231
+ nested call does not create an independent commit or savepoint.
232
+ - Inside a `#configure` block, every fiber on the configuring thread sees the
233
+ in-progress config. Other threads keep seeing the last published one until the
234
+ block completes.
235
+
236
+ Thread safety covers the config structure. User-supplied callbacks such as
237
+ `retry_if`, `on_retry`, and `on_give_up` can still hold mutable state. The caller
238
+ must synchronize that state if the callback can run from multiple threads.
239
+
174
240
  ### Override
175
241
 
176
242
  `#with_override` is a block-scoped API for forcing retry options that should
@@ -208,8 +274,13 @@ do not inherit it. This makes `#with_override` safe to use in parallel test
208
274
  runners. Fibers running inside the same thread share the thread's active
209
275
  override.
210
276
 
211
- `#with_override` stores the provided options directly. Do not mutate the
212
- options hash or nested values for the duration of the block.
277
+ `#with_override` stores the provided options hash **by reference** and reads
278
+ from it on every attempt while the block runs. Treat the hash and all of its
279
+ nested values as immutable for the duration of the block: do not mutate them
280
+ from inside the block, and do not mutate them from another thread or fiber that
281
+ shares this thread's active override. Mutating the options mid-block results in
282
+ undefined retry behavior. If options must be computed, build the hash before
283
+ calling `#with_override` and do not retain a reference you will later mutate.
213
284
 
214
285
  For test-integration patterns (RSpec `around`, helper methods, Minitest, etc.),
215
286
  see [docs/testing.md](docs/testing.md).
@@ -219,6 +290,8 @@ see [docs/testing.md](docs/testing.md).
219
290
  This example will only retry on a `Timeout::Error`, retry 3 times and sleep for a full second before each try.
220
291
 
221
292
  ```ruby
293
+ require "timeout"
294
+
222
295
  Retriable.retriable(on: Timeout::Error, tries: 3, base_interval: 1) do
223
296
  # code here...
224
297
  end
@@ -227,6 +300,8 @@ end
227
300
  You can also specify multiple errors to retry on by passing an array of exceptions.
228
301
 
229
302
  ```ruby
303
+ require "timeout"
304
+
230
305
  Retriable.retriable(on: [Timeout::Error, Errno::ECONNRESET]) do
231
306
  # code here...
232
307
  end
@@ -244,28 +319,6 @@ Retriable.retriable(on: {
244
319
  end
245
320
  ```
246
321
 
247
- #### Migrating off `timeout:`
248
-
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:
254
-
255
- ```ruby
256
- require "timeout"
257
-
258
- Retriable.retriable(on: Timeout::Error, tries: 3) do
259
- Timeout.timeout(5) do
260
- # code here...
261
- end
262
- end
263
- ```
264
-
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
322
  If you need millisecond units of time for the sleep interval:
270
323
 
271
324
  ```ruby
@@ -345,9 +398,11 @@ Retriable.retriable(on_retry: do_this_on_each_retry) do
345
398
  end
346
399
  ```
347
400
 
401
+ > **Note:** On the final rescued attempt — when Retriable is about to give up because `tries` are exhausted — `on_retry` still fires (before `on_give_up`; see below), but `next_interval` is **`nil`** because there is no next retry. Guard any handler that does arithmetic or formatting on `next_interval` (for example `next_interval&.*(1000)`, or `if next_interval`), and avoid unconditionally logging messages like `"retrying in #{next_interval}s"` since no retry is coming. This mirrors the `nil` contract documented for [`on_give_up`](#callbacks) below.
402
+
348
403
  #### Disabling a Configured Callback Per Call
349
404
 
350
- 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.
405
+ 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`.
351
406
 
352
407
  ```ruby
353
408
  Retriable.configure do |c|
@@ -365,6 +420,28 @@ Retriable.retriable(on_retry: false) do
365
420
  end
366
421
  ```
367
422
 
423
+ 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`.
424
+
425
+ ```ruby
426
+ do_this_when_retries_stop = Proc.new do |exception, try, elapsed_time, next_interval, reason|
427
+ log "#{exception.class}: '#{exception.message}' - gave up after #{try} tries because #{reason}."
428
+ end
429
+
430
+ Retriable.retriable(on_give_up: do_this_when_retries_stop) do
431
+ # code here...
432
+ end
433
+ ```
434
+
435
+ 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.
436
+
437
+ 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.
438
+
439
+ 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".
440
+
441
+ `: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.
442
+
443
+ 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.
444
+
368
445
  ### Ensure/Else
369
446
 
370
447
  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:
@@ -392,7 +469,8 @@ Retriable.configure do |c|
392
469
  c.contexts[:aws] = {
393
470
  tries: 3,
394
471
  base_interval: 5,
395
- on_retry: Proc.new { puts 'Curse you, AWS!' }
472
+ on_retry: Proc.new { puts 'Curse you, AWS!' },
473
+ on_give_up: Proc.new { |_e, _try, _elapsed, _interval, reason| puts "Gave up on AWS: #{reason}" }
396
474
  }
397
475
  c.contexts[:mysql] = {
398
476
  tries: 10,
@@ -426,6 +504,9 @@ Retriable.with_context(:mysql, tries: 30) do
426
504
  end
427
505
  ```
428
506
 
507
+ `#with_context` requires a block and raises `ArgumentError` if called without
508
+ one.
509
+
429
510
  ## Kernel Extension
430
511
 
431
512
  If you want to call `Retriable.retriable` without the `Retriable` module prefix and you don't mind extending `Kernel`,