retriable 3.5.0 → 3.6.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: cfd17d793faa48456456036eee71083bf629041cfe6f3f673e3bc92ff0090a88
4
- data.tar.gz: 27946e72249fce2362c2a374c8f352dd27bd8edd4ee3deea61c80e4d0da8f5cd
3
+ metadata.gz: 575fe838c7ddda74e7e26abf2a3b74a9e4b6866bf20d33a86bc59d1b7c45770d
4
+ data.tar.gz: f8d81648c9ea122680f3e8eb2f71b7cbbfffc0049313123da850378bf5b3f77d
5
5
  SHA512:
6
- metadata.gz: 00424ca023aa864fd2a36016138d0dfd2a6a9030f64c7dd427bb296908b244847db139fcad208527f3bcb5cac075c5e0545fd5dd994f4fef48a31ef55baf2939
7
- data.tar.gz: a414ecfe2931a0bf3fb56f831d654c497a5a1b4d37e4dbe1acf1cbd646083f15bbd85271afcf8f5b9b9f6c484c9f4c5b34ef462b35b2ffd7ec4371b50e7769ab
6
+ metadata.gz: bff5a1d9a0efec882841085a306290d11ccd9f20c0622b8a27c733c13981a68c681a562d80eb267c17b866a816a03d514a92b4a17883a1d4180d835f2b76fd02
7
+ data.tar.gz: 9da2cfea5cf807d352ed899926b0fa9e45d825935697788163dfbd5cf7c47afe7b0cfd7b20af5b29ce61f84d5d923181b7a671e21e3cafd709b15f922c68abf8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # HEAD
2
2
 
3
+ ## 3.6.0
4
+
5
+ - 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.
6
+
7
+ ## 3.5.1
8
+
9
+ - 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.
10
+
3
11
  ## 3.5.0
4
12
 
5
13
  - Fix: Do not count skipped sleep intervals against `max_elapsed_time` when `sleep_disabled` is true.
data/README.md CHANGED
@@ -32,7 +32,7 @@ require 'retriable'
32
32
  In your Gemfile:
33
33
 
34
34
  ```ruby
35
- gem 'retriable', '~> 3.5'
35
+ gem 'retriable', '~> 3.6'
36
36
  ```
37
37
 
38
38
  ## Usage
@@ -94,6 +94,8 @@ Here are the available options, in some vague order of relevance to most common
94
94
  | **`intervals`** | `nil` | Skip generated intervals and provide your own array of intervals in seconds. [Read more](#custom-interval-array). |
95
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. |
96
96
 
97
+ Timing options are validated before retrying. `tries` must be a positive integer when Retriable generates intervals. `base_interval`, `max_interval`, `multiplier`, `max_elapsed_time`, and `timeout` must be non-negative numbers, with `max_elapsed_time` and `timeout` also accepting `nil`. `rand_factor` must be a number from `0` through `1`. If provided, `intervals` must be an array of non-negative numbers; because it replaces generated intervals, it also overrides `tries`, `base_interval`, `max_interval`, `rand_factor`, and `multiplier` validation.
98
+
97
99
  #### Configuring Which Options to Retry With :on
98
100
 
99
101
  **`:on`** Can take the form:
@@ -147,31 +149,46 @@ end
147
149
 
148
150
  ### Override
149
151
 
150
- If you need to force values globally (including over per-call options), use
151
- `#override`:
152
+ `#with_override` is a block-scoped API for forcing retry options that should
153
+ take precedence over both `#configure` defaults and per-call options. It is
154
+ primarily intended for tests — it lets a test force values like `tries: 1` or
155
+ `base_interval: 0` so the suite runs quickly and predictably, regardless of
156
+ the application's `#configure` defaults. In application code, prefer
157
+ `#configure` for app-level defaults and per-call options for caller-specific
158
+ values.
152
159
 
153
160
  ```ruby
154
- Retriable.override(tries: 1, base_interval: 0)
161
+ Retriable.with_override(tries: 1, base_interval: 0) do
162
+ Retriable.retriable do
163
+ # code here...
164
+ end
165
+ end
155
166
  ```
156
167
 
157
- `#override` precedence:
168
+ Precedence inside the block:
158
169
 
159
170
  ```
160
- override > local options > configure defaults
171
+ with_override > local options > configure defaults
161
172
  ```
162
173
 
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.
174
+ `#with_override` requires a block and raises `ArgumentError` if called without
175
+ one. The override is active only while the block is executing, and is
176
+ automatically restored to its previous value when the block returns or raises.
177
+ Nested `#with_override` calls work as expected: the inner block temporarily
178
+ replaces the active override and the outer override is restored when the
179
+ inner block exits.
166
180
 
167
- `#override` stores the provided options directly. Do not mutate the options hash
168
- or nested values after passing them to `#override`.
181
+ `#with_override` is scoped to the **current thread**. The active override
182
+ does not affect any other thread, and child threads spawned inside the block
183
+ do not inherit it. This makes `#with_override` safe to use in parallel test
184
+ runners. Fibers running inside the same thread share the thread's active
185
+ override.
169
186
 
170
- To clear an override:
187
+ `#with_override` stores the provided options directly. Do not mutate the
188
+ options hash or nested values for the duration of the block.
171
189
 
172
- ```ruby
173
- Retriable.reset_override
174
- ```
190
+ For test-integration patterns (RSpec `around`, helper methods, Minitest, etc.),
191
+ see [docs/testing.md](docs/testing.md).
175
192
 
176
193
  ### Example Usage
177
194
 
@@ -367,72 +384,16 @@ retriable_with_context(:api) do
367
384
  end
368
385
  ```
369
386
 
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.
373
-
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
385
-
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.
387
+ ## Testing
392
388
 
393
- Alternately, if you are using RSpec, you could override the Retriable configuration in your `spec_helper`.
389
+ `Retriable.with_override` is designed to short-circuit retries in your test
390
+ suite so failing blocks do not slow tests down. The simplest pattern is an
391
+ RSpec `around(:each)` hook (or your test framework's equivalent) that wraps
392
+ every example in `with_override(tries: 1, base_interval: 0)`.
394
393
 
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
- ```
394
+ For Rails integration, opting out of the override for specific tests, and
395
+ overriding configured contexts in tests, see
396
+ [docs/testing.md](docs/testing.md).
436
397
 
437
398
  ## Credits
438
399
 
data/docs/testing.md ADDED
@@ -0,0 +1,212 @@
1
+ # Testing with Retriable
2
+
3
+ `Retriable.with_override` exists primarily for tests. It lets a test force
4
+ retry options like `tries: 1` or `base_interval: 0` so the suite runs quickly
5
+ and predictably, regardless of what the application's `Retriable.configure`
6
+ defaults are.
7
+
8
+ `with_override` is block-scoped: the override is active inside the block and
9
+ restored to its previous value (which is usually "no override") when the block
10
+ exits, even if the block raises. It is also thread-local — overrides set in
11
+ one thread do not affect other threads — so it is safe for parallel test
12
+ runners. See the README for the full API contract.
13
+
14
+ ## RSpec
15
+
16
+ ### Apply an override to every test
17
+
18
+ Use `around(:each)` in `RSpec.configure` so every test in the suite runs inside
19
+ the override. This is the most common pattern:
20
+
21
+ ```ruby
22
+ RSpec.configure do |config|
23
+ config.around(:each) do |example|
24
+ Retriable.with_override(tries: 1, base_interval: 0) do
25
+ example.run
26
+ end
27
+ end
28
+ end
29
+ ```
30
+
31
+ ### Apply an override to a specific context
32
+
33
+ ```ruby
34
+ describe MyClient do
35
+ context "when external calls should not retry" do
36
+ around(:each) do |example|
37
+ Retriable.with_override(tries: 1) { example.run }
38
+ end
39
+
40
+ it "fails fast" do
41
+ # `with_override(tries: 1)` is active here
42
+ end
43
+ end
44
+ end
45
+ ```
46
+
47
+ ### Apply an override to a single test
48
+
49
+ Wrap the test body directly:
50
+
51
+ ```ruby
52
+ it "does the thing without waiting" do
53
+ Retriable.with_override(tries: 1, base_interval: 0) do
54
+ # test body
55
+ end
56
+ end
57
+ ```
58
+
59
+ ### Reusable helper
60
+
61
+ Wrap a common configuration in a helper to keep tests readable:
62
+
63
+ ```ruby
64
+ module RetriableHelpers
65
+ def with_fast_retries(&block)
66
+ Retriable.with_override(tries: 1, base_interval: 0, &block)
67
+ end
68
+ end
69
+
70
+ RSpec.configure do |config|
71
+ config.include RetriableHelpers
72
+ end
73
+
74
+ # In a spec:
75
+ it "does the thing" do
76
+ with_fast_retries do
77
+ # test body
78
+ end
79
+ end
80
+ ```
81
+
82
+ ## Minitest
83
+
84
+ ```ruby
85
+ class MyClientTest < Minitest::Test
86
+ def around
87
+ Retriable.with_override(tries: 1, base_interval: 0) { yield }
88
+ end
89
+
90
+ def test_fails_fast
91
+ # `with_override(tries: 1)` is active here
92
+ end
93
+ end
94
+ ```
95
+
96
+ Older Minitest versions without `around` can wrap the test body directly:
97
+
98
+ ```ruby
99
+ def test_fails_fast
100
+ Retriable.with_override(tries: 1) do
101
+ # test body
102
+ end
103
+ end
104
+ ```
105
+
106
+ ## Short-Circuiting Retriable in Your Test Suite
107
+
108
+ When you are running tests for your app, the default retry behavior (3 tries
109
+ with exponential backoff) makes failing blocks take a long time. To short-circuit
110
+ retries — including calls that pass local options — set `tries: 1` and disable
111
+ backoff using `with_override`.
112
+
113
+ ### Under Rails
114
+
115
+ Keep shared defaults in `Retriable.configure` and apply test-only overrides via
116
+ RSpec's `around` hook (or your test framework's equivalent):
117
+
118
+ ```ruby
119
+ # config/initializers/retriable.rb
120
+ Retriable.configure do |c|
121
+ c.tries = 3
122
+ c.base_interval = 0.5
123
+ c.rand_factor = 0.5
124
+ end
125
+
126
+ # spec/spec_helper.rb (or equivalent)
127
+ RSpec.configure do |config|
128
+ config.around(:each) do |example|
129
+ Retriable.with_override(tries: 1, base_interval: 0, rand_factor: 0) do
130
+ example.run
131
+ end
132
+ end
133
+ end
134
+ ```
135
+
136
+ If a specific test needs normal retry behavior, opt out by running outside the
137
+ `around` hook. The cleanest way is to tag the example and skip the hook for
138
+ tagged examples:
139
+
140
+ ```ruby
141
+ config.around(:each, retriable: :real) { |example| example.run }
142
+ config.around(:each) do |example|
143
+ next example.run if example.metadata[:retriable] == :real
144
+
145
+ Retriable.with_override(tries: 1, base_interval: 0, rand_factor: 0) do
146
+ example.run
147
+ end
148
+ end
149
+
150
+ it "exercises the real retry behavior", retriable: :real do
151
+ # `with_override` is not applied here
152
+ end
153
+ ```
154
+
155
+ ### Overriding Configured Contexts in Tests
156
+
157
+ If you have configured contexts, top-level override values (such as `tries: 1`)
158
+ already take precedence over context-specific values. To override
159
+ context-specific options as well (for example, clearing a context's
160
+ `:intervals` array or shrinking its `:on` exception list), pass `:contexts` to
161
+ `with_override`.
162
+
163
+ Given a configured `google_api` context:
164
+
165
+ ```ruby
166
+ # config/initializers/retriable.rb
167
+ Retriable.configure do |c|
168
+ c.contexts[:google_api] = {
169
+ tries: 5,
170
+ base_interval: 3,
171
+ on: [
172
+ Net::ReadTimeout,
173
+ Signet::AuthorizationError,
174
+ Errno::ECONNRESET,
175
+ OpenSSL::SSL::SSLError,
176
+ ],
177
+ }
178
+ end
179
+ ```
180
+
181
+ You can override both top-level defaults and per-context options in your
182
+ test setup:
183
+
184
+ ```ruby
185
+ RSpec.configure do |config|
186
+ config.around(:each) do |example|
187
+ context_overrides = Retriable.config.contexts.each_key.with_object({}) do |key, h|
188
+ h[key] = { tries: 1, base_interval: 0 }
189
+ end
190
+
191
+ Retriable.with_override(
192
+ multiplier: 1.0,
193
+ rand_factor: 0.0,
194
+ base_interval: 0,
195
+ contexts: context_overrides,
196
+ ) do
197
+ example.run
198
+ end
199
+ end
200
+ end
201
+ ```
202
+
203
+ ## Notes
204
+
205
+ - The override is automatically cleared when the block exits, including when
206
+ the block raises. You do not need to clean up after the block.
207
+ - `with_override` calls nest: an inner block temporarily replaces the active
208
+ override, and the outer override is restored when the inner block exits.
209
+ - Overrides are thread-local. Child threads spawned inside the block do not
210
+ inherit it. If a test spawns background threads that themselves call
211
+ `Retriable.retriable`, wrap each background thread's body in its own
212
+ `with_override` call.
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "exponential_backoff"
4
+ require_relative "validation"
4
5
 
5
6
  module Retriable
6
7
  class Config
8
+ include Validation
9
+
7
10
  ATTRIBUTES = (ExponentialBackoff::ATTRIBUTES + %i[
8
11
  sleep_disabled
9
12
  max_elapsed_time
@@ -39,6 +42,8 @@ module Retriable
39
42
 
40
43
  instance_variable_set(:"@#{k}", v)
41
44
  end
45
+
46
+ validate!
42
47
  end
43
48
 
44
49
  def to_h
@@ -46,5 +51,28 @@ module Retriable
46
51
  hash[key] = public_send(key)
47
52
  end
48
53
  end
54
+
55
+ def validate!
56
+ validate_optional_non_negative_number(:max_elapsed_time, max_elapsed_time)
57
+ validate_optional_non_negative_number(:timeout, timeout)
58
+ validate_intervals
59
+ return if intervals
60
+
61
+ validate_positive_integer(:tries, tries)
62
+ validate_non_negative_number(:base_interval, base_interval)
63
+ validate_non_negative_number(:multiplier, multiplier)
64
+ validate_non_negative_number(:max_interval, max_interval)
65
+ validate_rand_factor
66
+ end
67
+
68
+ private
69
+
70
+ def validate_intervals
71
+ return if intervals.nil?
72
+ raise ArgumentError, "intervals must be an Array" unless intervals.is_a?(Array)
73
+ return if intervals.all? { |interval| finite_number?(interval) && interval >= 0 }
74
+
75
+ raise ArgumentError, "intervals must contain only non-negative numbers"
76
+ end
49
77
  end
50
78
  end
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "validation"
4
+
3
5
  module Retriable
4
6
  class ExponentialBackoff
7
+ include Validation
8
+
5
9
  ATTRIBUTES = %i[
6
10
  tries
7
11
  base_interval
@@ -24,6 +28,8 @@ module Retriable
24
28
 
25
29
  instance_variable_set(:"@#{k}", v)
26
30
  end
31
+
32
+ validate!
27
33
  end
28
34
 
29
35
  def intervals
@@ -38,6 +44,14 @@ module Retriable
38
44
 
39
45
  private
40
46
 
47
+ def validate!
48
+ validate_non_negative_integer(:tries, tries)
49
+ validate_non_negative_number(:base_interval, base_interval)
50
+ validate_non_negative_number(:multiplier, multiplier)
51
+ validate_non_negative_number(:max_interval, max_interval)
52
+ validate_rand_factor
53
+ end
54
+
41
55
  def randomize(interval)
42
56
  delta = rand_factor * interval.to_f
43
57
  min = interval - delta
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Retriable
4
+ module Validation
5
+ private
6
+
7
+ def validate_positive_integer(name, value)
8
+ return if value.is_a?(Integer) && value.positive?
9
+
10
+ raise ArgumentError, "#{name} must be a positive integer"
11
+ end
12
+
13
+ def validate_non_negative_integer(name, value)
14
+ return if value.is_a?(Integer) && value >= 0
15
+
16
+ raise ArgumentError, "#{name} must be a non-negative integer"
17
+ end
18
+
19
+ def validate_non_negative_number(name, value)
20
+ return if finite_number?(value) && value >= 0
21
+
22
+ raise ArgumentError, "#{name} must be a non-negative number"
23
+ end
24
+
25
+ def validate_optional_non_negative_number(name, value)
26
+ return if value.nil?
27
+
28
+ validate_non_negative_number(name, value)
29
+ end
30
+
31
+ def validate_rand_factor
32
+ return if finite_number?(rand_factor) && rand_factor >= 0 && rand_factor <= 1
33
+
34
+ raise ArgumentError, "rand_factor must be between 0 and 1"
35
+ end
36
+
37
+ def finite_number?(value)
38
+ value.is_a?(Numeric) && value.to_f.finite?
39
+ end
40
+ end
41
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Retriable
4
- VERSION = "3.5.0"
4
+ VERSION = "3.6.0"
5
5
  end
data/lib/retriable.rb CHANGED
@@ -6,6 +6,13 @@ require_relative "retriable/exponential_backoff"
6
6
  require_relative "retriable/version"
7
7
 
8
8
  module Retriable
9
+ # Thread-local storage key for the active #with_override block.
10
+ # We deliberately use Thread#thread_variable_set/get (true thread-local)
11
+ # rather than Thread.current[] (fiber-local) so that fibers within a thread
12
+ # share the same override. Changing this to Thread.current[] would silently
13
+ # break callers that use fiber-based concurrency.
14
+ OVERRIDE_THREAD_KEY = :retriable_override
15
+
9
16
  module_function
10
17
 
11
18
  def configure
@@ -16,15 +23,19 @@ module Retriable
16
23
  @config ||= Config.new
17
24
  end
18
25
 
19
- def override(opts = {})
20
- raise ArgumentError, "empty override options are not allowed; use reset_override instead" if opts.empty?
26
+ def with_override(opts = {})
27
+ raise ArgumentError, "empty override options are not allowed" if opts.empty?
28
+ raise ArgumentError, "with_override requires a block" unless block_given?
21
29
 
22
30
  validate_override_options(opts)
23
- @override_config = opts
24
- end
25
31
 
26
- def reset_override
27
- @override_config = nil
32
+ previous = Thread.current.thread_variable_get(OVERRIDE_THREAD_KEY)
33
+ Thread.current.thread_variable_set(OVERRIDE_THREAD_KEY, opts)
34
+ begin
35
+ yield
36
+ ensure
37
+ Thread.current.thread_variable_set(OVERRIDE_THREAD_KEY, previous)
38
+ end
28
39
  end
29
40
 
30
41
  def with_context(context_key, options = {}, &block)
@@ -41,12 +52,16 @@ module Retriable
41
52
  end
42
53
 
43
54
  def retriable(opts = {}, &block)
44
- local_config = if opts.empty? && !@override_config
55
+ override_config = current_override
56
+ local_config = if opts.empty? && !override_config
45
57
  config
46
58
  else
47
- Config.new(apply_override_options(config.to_h.merge(opts), @override_config))
59
+ Config.new(apply_override_options(config.to_h.merge(opts), override_config))
48
60
  end
49
61
 
62
+ # Config is mutable through `configure`, so validate again immediately before use.
63
+ local_config.validate!
64
+
50
65
  tries = local_config.tries
51
66
  intervals = build_intervals(local_config, tries)
52
67
  timeout = local_config.timeout
@@ -196,10 +211,15 @@ module Retriable
196
211
  end
197
212
 
198
213
  def override_contexts
199
- contexts = @override_config && @override_config[:contexts]
214
+ override_config = current_override
215
+ contexts = override_config && override_config[:contexts]
200
216
  contexts.is_a?(Hash) ? contexts : {}
201
217
  end
202
218
 
219
+ def current_override
220
+ Thread.current.thread_variable_get(OVERRIDE_THREAD_KEY)
221
+ end
222
+
203
223
  private_class_method(
204
224
  :validate_override_options,
205
225
  :validate_context_override_options,
@@ -215,5 +235,6 @@ module Retriable
215
235
  :context_options_for,
216
236
  :config_contexts,
217
237
  :override_contexts,
238
+ :current_override,
218
239
  )
219
240
  end
data/spec/config_spec.rb CHANGED
@@ -56,4 +56,13 @@ describe Retriable::Config do
56
56
  it "raises errors on invalid configuration" do
57
57
  expect { described_class.new(does_not_exist: 123) }.to raise_error(ArgumentError, /not a valid option/)
58
58
  end
59
+
60
+ it "raises errors on invalid timing configuration" do
61
+ 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
+ end
64
+
65
+ it "raises errors when intervals is not an array" do
66
+ expect { described_class.new(intervals: "1") }.to raise_error(ArgumentError, /intervals must be an Array/)
67
+ end
59
68
  end
@@ -9,7 +9,7 @@ describe Retriable do
9
9
 
10
10
  before(:each) do
11
11
  described_class.instance_variable_set(:@config, nil)
12
- described_class.reset_override
12
+ Thread.current.thread_variable_set(Retriable::OVERRIDE_THREAD_KEY, nil)
13
13
  described_class.configure { |c| c.sleep_disabled = true }
14
14
  @tries = 0
15
15
  @next_interval_table = {}
@@ -375,6 +375,37 @@ describe Retriable do
375
375
  it "raises ArgumentError on invalid options" do
376
376
  expect { described_class.retriable(does_not_exist: 123) { increment_tries } }.to raise_error(ArgumentError)
377
377
  end
378
+
379
+ it "raises ArgumentError when tries is not a positive integer" do
380
+ expect { described_class.retriable(tries: 1.5) { increment_tries } }
381
+ .to raise_error(ArgumentError, /tries/)
382
+ end
383
+
384
+ it "raises ArgumentError when an interval is negative" do
385
+ expect { described_class.retriable(intervals: [-1]) { increment_tries } }
386
+ .to raise_error(ArgumentError, /intervals/)
387
+ end
388
+
389
+ it "raises ArgumentError when configured timing options become invalid" do
390
+ described_class.configure { |config| config.tries = 0 }
391
+
392
+ expect { described_class.retriable { increment_tries } }
393
+ .to raise_error(ArgumentError, /tries/)
394
+ end
395
+
396
+ it "does not validate generated backoff options when intervals are provided" do
397
+ described_class.retriable(intervals: [0], tries: 0, rand_factor: 1.1) { increment_tries }
398
+
399
+ expect(@tries).to eq(1)
400
+ end
401
+
402
+ it "allows an empty interval array as one attempt" do
403
+ expect do
404
+ described_class.retriable(intervals: []) { increment_tries_with_exception }
405
+ end.to raise_error(StandardError)
406
+
407
+ expect(@tries).to eq(1)
408
+ end
378
409
  end
379
410
 
380
411
  context "#configure" do
@@ -384,8 +415,7 @@ describe Retriable do
384
415
  with_context
385
416
  configure
386
417
  config
387
- override
388
- reset_override
418
+ with_override
389
419
  ]
390
420
 
391
421
  expect(described_class.singleton_methods(false)).to match_array(public_api_methods)
@@ -396,25 +426,23 @@ describe Retriable do
396
426
  end
397
427
  end
398
428
 
399
- context "#override" do
400
- after(:each) do
401
- described_class.reset_override
402
- end
403
-
429
+ context "#with_override" do
404
430
  it "takes precedence over both global config and local options" do
405
431
  described_class.configure { |c| c.tries = 2 }
406
- described_class.override(tries: 1)
407
432
 
408
- expect { described_class.retriable(tries: 10) { increment_tries_with_exception } }.to raise_error(StandardError)
433
+ described_class.with_override(tries: 1) do
434
+ expect { described_class.retriable(tries: 10) { increment_tries_with_exception } }.to raise_error(StandardError)
435
+ end
436
+
409
437
  expect(@tries).to eq(1)
410
438
  end
411
439
 
412
440
  it "lets override tries take precedence over local intervals" do
413
- described_class.override(tries: 1)
414
-
415
- expect do
416
- described_class.retriable(intervals: [0.5, 1.0]) { increment_tries_with_exception }
417
- end.to raise_error(StandardError)
441
+ described_class.with_override(tries: 1) do
442
+ expect do
443
+ described_class.retriable(intervals: [0.5, 1.0]) { increment_tries_with_exception }
444
+ end.to raise_error(StandardError)
445
+ end
418
446
 
419
447
  expect(@tries).to eq(1)
420
448
  end
@@ -423,9 +451,11 @@ describe Retriable do
423
451
  described_class.configure do |c|
424
452
  c.contexts[:api] = { intervals: [0.5, 1.0] }
425
453
  end
426
- described_class.override(tries: 1)
427
454
 
428
- expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
455
+ described_class.with_override(tries: 1) do
456
+ expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
457
+ end
458
+
429
459
  expect(@tries).to eq(1)
430
460
  end
431
461
 
@@ -433,31 +463,34 @@ describe Retriable do
433
463
  described_class.configure do |c|
434
464
  c.contexts[:api] = { intervals: [0.5, 1.0] }
435
465
  end
436
- described_class.override(contexts: { api: { tries: 1 } })
437
466
 
438
- expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
467
+ described_class.with_override(contexts: { api: { tries: 1 } }) do
468
+ expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
469
+ end
470
+
439
471
  expect(@tries).to eq(1)
440
472
  end
441
473
 
442
474
  it "replaces hash-valued options instead of deep-merging them" do
443
- described_class.override(on: { NonStandardError => nil })
444
-
445
- expect do
446
- described_class.retriable(on: { StandardError => nil }, tries: 2) { increment_tries_with_exception }
447
- end.to raise_error(StandardError)
475
+ described_class.with_override(on: { NonStandardError => nil }) do
476
+ expect do
477
+ described_class.retriable(on: { StandardError => nil }, tries: 2) { increment_tries_with_exception }
478
+ end.to raise_error(StandardError)
479
+ end
448
480
 
449
481
  expect(@tries).to eq(1)
450
482
  end
451
483
 
452
484
  it "can override local intervals with nil to use configured backoff" do
453
485
  described_class.configure { |c| c.tries = 3 }
454
- described_class.override(intervals: nil)
455
486
 
456
- expect do
457
- described_class.retriable(intervals: [0.5, 1.0], on_retry: time_table_handler) do
458
- increment_tries_with_exception
459
- end
460
- end.to raise_error(StandardError)
487
+ described_class.with_override(intervals: nil) do
488
+ expect do
489
+ described_class.retriable(intervals: [0.5, 1.0], on_retry: time_table_handler) do
490
+ increment_tries_with_exception
491
+ end
492
+ end.to raise_error(StandardError)
493
+ end
461
494
 
462
495
  expect(@tries).to eq(3)
463
496
  expect(@next_interval_table[1]).to be_between(0.0, 1.0)
@@ -468,23 +501,26 @@ describe Retriable do
468
501
  c.contexts[:api] = { tries: 3, base_interval: 1.0 }
469
502
  end
470
503
 
471
- described_class.override(contexts: { api: { tries: 1 } })
504
+ described_class.with_override(contexts: { api: { tries: 1 } }) do
505
+ described_class.with_context(:api, tries: 10) { increment_tries }
506
+ end
472
507
 
473
- described_class.with_context(:api, tries: 10) { increment_tries }
474
508
  expect(@tries).to eq(1)
475
509
  end
476
510
 
477
511
  it "can define a context only in override config" do
478
- described_class.override(contexts: { test_only: { tries: 1 } })
512
+ described_class.with_override(contexts: { test_only: { tries: 1 } }) do
513
+ described_class.with_context(:test_only) { increment_tries }
514
+ end
479
515
 
480
- described_class.with_context(:test_only) { increment_tries }
481
516
  expect(@tries).to eq(1)
482
517
  end
483
518
 
484
519
  it "does not apply context-only overrides to plain retriable calls" do
485
- described_class.override(contexts: { api: { tries: 1 } })
520
+ described_class.with_override(contexts: { api: { tries: 1 } }) do
521
+ expect { described_class.retriable(tries: 3) { increment_tries_with_exception } }.to raise_error(StandardError)
522
+ end
486
523
 
487
- expect { described_class.retriable(tries: 3) { increment_tries_with_exception } }.to raise_error(StandardError)
488
524
  expect(@tries).to eq(3)
489
525
  end
490
526
 
@@ -492,21 +528,24 @@ describe Retriable do
492
528
  described_class.configure do |c|
493
529
  c.contexts[:api] = { tries: 3, on: NonStandardError }
494
530
  end
495
- described_class.override(tries: 1)
496
531
 
497
- expect { described_class.with_context(:api) { increment_tries_with_exception(NonStandardError) } }
498
- .to raise_error(NonStandardError)
532
+ described_class.with_override(tries: 1) do
533
+ expect { described_class.with_context(:api) { increment_tries_with_exception(NonStandardError) } }
534
+ .to raise_error(NonStandardError)
535
+ end
536
+
499
537
  expect(@tries).to eq(1)
500
538
  end
501
539
 
502
540
  it "combines local options with override-only contexts" do
503
- described_class.override(contexts: { api: { tries: 1 } })
541
+ described_class.with_override(contexts: { api: { tries: 1 } }) do
542
+ expect do
543
+ described_class.with_context(:api, on: NonStandardError) do
544
+ increment_tries_with_exception(NonStandardError)
545
+ end
546
+ end.to raise_error(NonStandardError)
547
+ end
504
548
 
505
- expect do
506
- described_class.with_context(:api, on: NonStandardError) do
507
- increment_tries_with_exception(NonStandardError)
508
- end
509
- end.to raise_error(NonStandardError)
510
549
  expect(@tries).to eq(1)
511
550
  end
512
551
 
@@ -515,9 +554,10 @@ describe Retriable do
515
554
  c.contexts[:api] = { tries: 1 }
516
555
  end
517
556
 
518
- described_class.override(tries: 1)
557
+ described_class.with_override(tries: 1) do
558
+ described_class.with_context(:api) { increment_tries }
559
+ end
519
560
 
520
- described_class.with_context(:api) { increment_tries }
521
561
  expect(@tries).to eq(1)
522
562
  end
523
563
 
@@ -525,9 +565,10 @@ describe Retriable do
525
565
  begin
526
566
  described_class.configure { |c| c.contexts = nil }
527
567
 
528
- described_class.override(contexts: { api: { tries: 1 } })
568
+ described_class.with_override(contexts: { api: { tries: 1 } }) do
569
+ described_class.with_context(:api) { increment_tries }
570
+ end
529
571
 
530
- described_class.with_context(:api) { increment_tries }
531
572
  expect(@tries).to eq(1)
532
573
  ensure
533
574
  described_class.configure { |c| c.contexts = {} }
@@ -539,9 +580,10 @@ describe Retriable do
539
580
  c.contexts[:api] = { tries: 1 }
540
581
  end
541
582
 
542
- described_class.override(contexts: nil)
583
+ described_class.with_override(contexts: nil) do
584
+ described_class.with_context(:api) { increment_tries }
585
+ end
543
586
 
544
- described_class.with_context(:api) { increment_tries }
545
587
  expect(@tries).to eq(1)
546
588
  end
547
589
 
@@ -550,9 +592,10 @@ describe Retriable do
550
592
  c.contexts[:api] = { tries: 1 }
551
593
  end
552
594
 
553
- described_class.override(contexts: 123)
595
+ described_class.with_override(contexts: 123) do
596
+ described_class.with_context(:api) { increment_tries }
597
+ end
554
598
 
555
- described_class.with_context(:api) { increment_tries }
556
599
  expect(@tries).to eq(1)
557
600
  end
558
601
 
@@ -561,9 +604,10 @@ describe Retriable do
561
604
  c.contexts[:api] = { tries: 2 }
562
605
  end
563
606
 
564
- described_class.override(contexts: { api: 123 })
607
+ described_class.with_override(contexts: { api: 123 }) do
608
+ expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
609
+ end
565
610
 
566
- expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
567
611
  expect(@tries).to eq(2)
568
612
  end
569
613
 
@@ -572,10 +616,10 @@ describe Retriable do
572
616
  c.contexts[:configured] = { tries: 2 }
573
617
  end
574
618
 
575
- described_class.override(contexts: { override_only: { tries: 1 } })
576
-
577
- expect { described_class.with_context(:missing) { increment_tries } }
578
- .to raise_error(ArgumentError, /override_only/)
619
+ described_class.with_override(contexts: { override_only: { tries: 1 } }) do
620
+ expect { described_class.with_context(:missing) { increment_tries } }
621
+ .to raise_error(ArgumentError, /override_only/)
622
+ end
579
623
  end
580
624
 
581
625
  it "does not snapshot configured contexts when adding override-only contexts" do
@@ -583,37 +627,200 @@ describe Retriable do
583
627
  c.contexts[:api] = { tries: 2 }
584
628
  end
585
629
 
586
- described_class.override(contexts: { test_only: { tries: 1 } })
630
+ described_class.with_override(contexts: { test_only: { tries: 1 } }) do
631
+ described_class.configure do |c|
632
+ c.contexts[:api] = { tries: 5 }
633
+ end
587
634
 
588
- described_class.configure do |c|
589
- c.contexts[:api] = { tries: 5 }
635
+ expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
590
636
  end
591
637
 
592
- expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
593
638
  expect(@tries).to eq(5)
594
639
  end
595
640
 
596
641
  it "raises ArgumentError on invalid override options" do
597
- expect { described_class.override(does_not_exist: 123) }.to raise_error(ArgumentError)
642
+ expect { described_class.with_override(does_not_exist: 123) { :noop } }.to raise_error(ArgumentError)
598
643
  end
599
644
 
600
645
  it "raises ArgumentError on empty override options" do
601
- expect { described_class.override({}) }.to raise_error(ArgumentError, /empty override/)
646
+ expect { described_class.with_override({}) { :noop } }.to raise_error(ArgumentError, /empty override/)
647
+ end
648
+
649
+ it "raises ArgumentError when called without a block" do
650
+ expect { described_class.with_override(tries: 1) }.to raise_error(ArgumentError, /requires a block/)
602
651
  end
603
652
 
604
653
  it "raises ArgumentError on invalid context override options" do
605
- expect { described_class.override(contexts: { api: { does_not_exist: 123 } }) }
654
+ expect { described_class.with_override(contexts: { api: { does_not_exist: 123 } }) { :noop } }
606
655
  .to raise_error(ArgumentError, /does_not_exist is not a valid option/)
607
656
  end
608
657
 
609
- it "does not copy the provided override options" do
610
- opts = { tries: 1 }
611
- described_class.override(opts)
658
+ it "clears the override after the block returns" do
659
+ described_class.with_override(tries: 1) do
660
+ # active here
661
+ end
612
662
 
613
- opts[:tries] = 2
663
+ expect { described_class.retriable(tries: 3) { increment_tries_with_exception } }.to raise_error(StandardError)
664
+ expect(@tries).to eq(3)
665
+ end
614
666
 
615
- expect { described_class.retriable(tries: 10) { increment_tries_with_exception } }.to raise_error(StandardError)
616
- expect(@tries).to eq(2)
667
+ it "clears the override when the block raises" do
668
+ expect do
669
+ described_class.with_override(tries: 1) { raise "boom" }
670
+ end.to raise_error(RuntimeError, "boom")
671
+
672
+ expect { described_class.retriable(tries: 3) { increment_tries_with_exception } }.to raise_error(StandardError)
673
+ expect(@tries).to eq(3)
674
+ end
675
+
676
+ it "returns the block's return value" do
677
+ result = described_class.with_override(tries: 1) { :return_value }
678
+ expect(result).to eq(:return_value)
679
+ end
680
+
681
+ it "restores the outer override when nested blocks exit" do
682
+ tries_seen = []
683
+ handler = ->(_exception, try, _elapsed, _next) { tries_seen << [Thread.current.object_id, try] }
684
+
685
+ described_class.with_override(tries: 2, on_retry: handler) do
686
+ described_class.with_override(tries: 4, on_retry: handler) do
687
+ expect { described_class.retriable { increment_tries_with_exception } }.to raise_error(StandardError)
688
+ end
689
+
690
+ # After the inner block exits, the outer tries: 2 override is restored.
691
+ @tries = 0
692
+ expect { described_class.retriable { increment_tries_with_exception } }.to raise_error(StandardError)
693
+ expect(@tries).to eq(2)
694
+ end
695
+ end
696
+ end
697
+
698
+ context "#with_override thread safety" do
699
+ # Coordinate threads with queues rather than sleep so tests are deterministic.
700
+ # sleep_disabled is already set to true in the top-level before(:each), so
701
+ # retriable calls do not actually sleep between attempts.
702
+
703
+ it "isolates overrides between threads" do
704
+ ready = Queue.new
705
+ proceed = Queue.new
706
+ results = {}
707
+ mutex = Mutex.new
708
+
709
+ threads = [1, 2].map do |id|
710
+ Thread.new do
711
+ described_class.with_override(tries: id) do
712
+ ready << true
713
+ proceed.pop
714
+ tries = 0
715
+ begin
716
+ described_class.retriable do
717
+ tries += 1
718
+ raise StandardError
719
+ end
720
+ rescue StandardError
721
+ mutex.synchronize { results[id] = tries }
722
+ end
723
+ end
724
+ end
725
+ end
726
+
727
+ 2.times { ready.pop }
728
+ 2.times { proceed << true }
729
+ threads.each(&:join)
730
+
731
+ expect(results).to eq(1 => 1, 2 => 2)
732
+ end
733
+
734
+ it "does not leak an active override into a sibling thread" do
735
+ override_active = Queue.new
736
+ sibling_done = Queue.new
737
+ sibling_tries = nil
738
+
739
+ setter = Thread.new do
740
+ described_class.with_override(tries: 1) do
741
+ override_active << true
742
+ sibling_done.pop
743
+ end
744
+ end
745
+
746
+ sibling = Thread.new do
747
+ override_active.pop
748
+ tries = 0
749
+ begin
750
+ described_class.retriable(tries: 3) do
751
+ tries += 1
752
+ raise StandardError
753
+ end
754
+ rescue StandardError
755
+ sibling_tries = tries
756
+ end
757
+ sibling_done << true
758
+ end
759
+
760
+ [setter, sibling].each(&:join)
761
+ expect(sibling_tries).to eq(3)
762
+ end
763
+
764
+ it "does not propagate an active override to a child thread" do
765
+ child_tries = nil
766
+
767
+ described_class.with_override(tries: 1) do
768
+ Thread.new do
769
+ tries = 0
770
+ begin
771
+ described_class.retriable(tries: 3) do
772
+ tries += 1
773
+ raise StandardError
774
+ end
775
+ rescue StandardError
776
+ child_tries = tries
777
+ end
778
+ end.join
779
+ end
780
+
781
+ expect(child_tries).to eq(3)
782
+ end
783
+
784
+ it "shares the active override with fibers in the same thread" do
785
+ fiber_tries = nil
786
+
787
+ Thread.new do
788
+ described_class.with_override(tries: 1) do
789
+ Fiber.new do
790
+ tries = 0
791
+ begin
792
+ described_class.retriable(tries: 10) do
793
+ tries += 1
794
+ raise StandardError
795
+ end
796
+ rescue StandardError
797
+ fiber_tries = tries
798
+ end
799
+ end.resume
800
+ end
801
+ end.join
802
+
803
+ expect(fiber_tries).to eq(1)
804
+ end
805
+
806
+ it "does not treat a main-thread override as a global default for other threads" do
807
+ other_thread_tries = nil
808
+
809
+ described_class.with_override(tries: 1) do
810
+ Thread.new do
811
+ tries = 0
812
+ begin
813
+ described_class.retriable(tries: 3) do
814
+ tries += 1
815
+ raise StandardError
816
+ end
817
+ rescue StandardError
818
+ other_thread_tries = tries
819
+ end
820
+ end.join
821
+ end
822
+
823
+ expect(other_thread_tries).to eq(3)
617
824
  end
618
825
  end
619
826
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: retriable
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.5.0
4
+ version: 3.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jack Chu
@@ -74,10 +74,12 @@ files:
74
74
  - Rakefile
75
75
  - bin/console
76
76
  - bin/setup
77
+ - docs/testing.md
77
78
  - lib/retriable.rb
78
79
  - lib/retriable/config.rb
79
80
  - lib/retriable/core_ext/kernel.rb
80
81
  - lib/retriable/exponential_backoff.rb
82
+ - lib/retriable/validation.rb
81
83
  - lib/retriable/version.rb
82
84
  - retriable.gemspec
83
85
  - sig/retriable.rbs