retriable 3.5.1 → 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: b0cb0bb3751f465d22addceebf5466b165871741e5820c7c3291d2e842f6f48a
4
- data.tar.gz: 8bef0641e9c3b0e39c04d79ffcbdaf4542790264ce6648f6540db933cfddbc54
3
+ metadata.gz: 575fe838c7ddda74e7e26abf2a3b74a9e4b6866bf20d33a86bc59d1b7c45770d
4
+ data.tar.gz: f8d81648c9ea122680f3e8eb2f71b7cbbfffc0049313123da850378bf5b3f77d
5
5
  SHA512:
6
- metadata.gz: 6c4e07023a30aa934d5397717344c480fb6af8dd56349fcbcb9b85b9ba5539a3f16d2b16a30cbb3682e4140a04f3ace9ecbf0602e68bc4a1a5c805230a031de6
7
- data.tar.gz: 4272451f1213b81537fd8f7cd85ced8a341b8f8dbf228493a42393b74cee3400e90eb9aa466ec3e404b98eef78b2a42ade5600b49c5d8d9ba7dd9e9b4ae1f444
6
+ metadata.gz: bff5a1d9a0efec882841085a306290d11ccd9f20c0622b8a27c733c13981a68c681a562d80eb267c17b866a816a03d514a92b4a17883a1d4180d835f2b76fd02
7
+ data.tar.gz: 9da2cfea5cf807d352ed899926b0fa9e45d825935697788163dfbd5cf7c47afe7b0cfd7b20af5b29ce61f84d5d923181b7a671e21e3cafd709b15f922c68abf8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
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
+
3
7
  ## 3.5.1
4
8
 
5
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.
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
@@ -149,31 +149,46 @@ end
149
149
 
150
150
  ### Override
151
151
 
152
- If you need to force values globally (including over per-call options), use
153
- `#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.
154
159
 
155
160
  ```ruby
156
- 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
157
166
  ```
158
167
 
159
- `#override` precedence:
168
+ Precedence inside the block:
160
169
 
161
170
  ```
162
- override > local options > configure defaults
171
+ with_override > local options > configure defaults
163
172
  ```
164
173
 
165
- `#override` uses process-global state. Once set, it affects every caller and
166
- thread until `#reset_override` runs. Prefer setting it once at boot (or in test
167
- 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.
168
180
 
169
- `#override` stores the provided options directly. Do not mutate the options hash
170
- 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.
171
186
 
172
- 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.
173
189
 
174
- ```ruby
175
- Retriable.reset_override
176
- ```
190
+ For test-integration patterns (RSpec `around`, helper methods, Minitest, etc.),
191
+ see [docs/testing.md](docs/testing.md).
177
192
 
178
193
  ### Example Usage
179
194
 
@@ -369,72 +384,16 @@ retriable_with_context(:api) do
369
384
  end
370
385
  ```
371
386
 
372
- ## Short Circuiting Retriable While Testing Your App
373
-
374
- 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.
375
-
376
- If you want to short-circuit retries in tests, including calls that pass local options, use `Retriable.override` and set `tries` to `1`.
377
-
378
- Under Rails, keep shared defaults in `Retriable.configure` and apply test-only overrides conditionally:
379
-
380
- ```ruby
381
- # config/initializers/retriable.rb
382
- Retriable.configure do |c|
383
- c.tries = 3
384
- c.base_interval = 0.5
385
- c.rand_factor = 0.5
386
- end
387
-
388
- if Rails.env.test?
389
- Retriable.override(tries: 1, base_interval: 0, rand_factor: 0)
390
- end
391
- ```
392
-
393
- 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
394
388
 
395
- 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)`.
396
393
 
397
- ```ruby
398
- # spec/spec_helper.rb
399
- Retriable.override(tries: 1, base_interval: 0, rand_factor: 0)
400
- ```
401
-
402
- 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`:
403
-
404
- For example assuming you have configured a `google_api` context:
405
-
406
- ```ruby
407
- # config/initializers/retriable.rb
408
- Retriable.configure do |c|
409
- c.contexts[:google_api] = {
410
- tries: 5,
411
- base_interval: 3,
412
- on: [
413
- Net::ReadTimeout,
414
- Signet::AuthorizationError,
415
- Errno::ECONNRESET,
416
- OpenSSL::SSL::SSLError
417
- ]
418
- }
419
- end
420
- ```
421
-
422
- Then in your test environment, you can override both top-level defaults and per-context options:
423
-
424
- ```ruby
425
- # Build context overrides from existing configured context keys
426
- context_overrides = {}
427
- Retriable.config.contexts.each_key do |key|
428
- context_overrides[key] = { tries: 1, base_interval: 0 }
429
- end
430
-
431
- Retriable.override(
432
- multiplier: 1.0,
433
- rand_factor: 0.0,
434
- base_interval: 0,
435
- contexts: context_overrides,
436
- )
437
- ```
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).
438
397
 
439
398
  ## Credits
440
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,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Retriable
4
- VERSION = "3.5.1"
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,10 +52,11 @@ 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
 
50
62
  # Config is mutable through `configure`, so validate again immediately before use.
@@ -199,10 +211,15 @@ module Retriable
199
211
  end
200
212
 
201
213
  def override_contexts
202
- contexts = @override_config && @override_config[:contexts]
214
+ override_config = current_override
215
+ contexts = override_config && override_config[:contexts]
203
216
  contexts.is_a?(Hash) ? contexts : {}
204
217
  end
205
218
 
219
+ def current_override
220
+ Thread.current.thread_variable_get(OVERRIDE_THREAD_KEY)
221
+ end
222
+
206
223
  private_class_method(
207
224
  :validate_override_options,
208
225
  :validate_context_override_options,
@@ -218,5 +235,6 @@ module Retriable
218
235
  :context_options_for,
219
236
  :config_contexts,
220
237
  :override_contexts,
238
+ :current_override,
221
239
  )
222
240
  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 = {}
@@ -415,8 +415,7 @@ describe Retriable do
415
415
  with_context
416
416
  configure
417
417
  config
418
- override
419
- reset_override
418
+ with_override
420
419
  ]
421
420
 
422
421
  expect(described_class.singleton_methods(false)).to match_array(public_api_methods)
@@ -427,25 +426,23 @@ describe Retriable do
427
426
  end
428
427
  end
429
428
 
430
- context "#override" do
431
- after(:each) do
432
- described_class.reset_override
433
- end
434
-
429
+ context "#with_override" do
435
430
  it "takes precedence over both global config and local options" do
436
431
  described_class.configure { |c| c.tries = 2 }
437
- described_class.override(tries: 1)
438
432
 
439
- 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
+
440
437
  expect(@tries).to eq(1)
441
438
  end
442
439
 
443
440
  it "lets override tries take precedence over local intervals" do
444
- described_class.override(tries: 1)
445
-
446
- expect do
447
- described_class.retriable(intervals: [0.5, 1.0]) { increment_tries_with_exception }
448
- 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
449
446
 
450
447
  expect(@tries).to eq(1)
451
448
  end
@@ -454,9 +451,11 @@ describe Retriable do
454
451
  described_class.configure do |c|
455
452
  c.contexts[:api] = { intervals: [0.5, 1.0] }
456
453
  end
457
- described_class.override(tries: 1)
458
454
 
459
- 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
+
460
459
  expect(@tries).to eq(1)
461
460
  end
462
461
 
@@ -464,31 +463,34 @@ describe Retriable do
464
463
  described_class.configure do |c|
465
464
  c.contexts[:api] = { intervals: [0.5, 1.0] }
466
465
  end
467
- described_class.override(contexts: { api: { tries: 1 } })
468
466
 
469
- 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
+
470
471
  expect(@tries).to eq(1)
471
472
  end
472
473
 
473
474
  it "replaces hash-valued options instead of deep-merging them" do
474
- described_class.override(on: { NonStandardError => nil })
475
-
476
- expect do
477
- described_class.retriable(on: { StandardError => nil }, tries: 2) { increment_tries_with_exception }
478
- 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
479
480
 
480
481
  expect(@tries).to eq(1)
481
482
  end
482
483
 
483
484
  it "can override local intervals with nil to use configured backoff" do
484
485
  described_class.configure { |c| c.tries = 3 }
485
- described_class.override(intervals: nil)
486
486
 
487
- expect do
488
- described_class.retriable(intervals: [0.5, 1.0], on_retry: time_table_handler) do
489
- increment_tries_with_exception
490
- end
491
- 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
492
494
 
493
495
  expect(@tries).to eq(3)
494
496
  expect(@next_interval_table[1]).to be_between(0.0, 1.0)
@@ -499,23 +501,26 @@ describe Retriable do
499
501
  c.contexts[:api] = { tries: 3, base_interval: 1.0 }
500
502
  end
501
503
 
502
- 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
503
507
 
504
- described_class.with_context(:api, tries: 10) { increment_tries }
505
508
  expect(@tries).to eq(1)
506
509
  end
507
510
 
508
511
  it "can define a context only in override config" do
509
- 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
510
515
 
511
- described_class.with_context(:test_only) { increment_tries }
512
516
  expect(@tries).to eq(1)
513
517
  end
514
518
 
515
519
  it "does not apply context-only overrides to plain retriable calls" do
516
- 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
517
523
 
518
- expect { described_class.retriable(tries: 3) { increment_tries_with_exception } }.to raise_error(StandardError)
519
524
  expect(@tries).to eq(3)
520
525
  end
521
526
 
@@ -523,21 +528,24 @@ describe Retriable do
523
528
  described_class.configure do |c|
524
529
  c.contexts[:api] = { tries: 3, on: NonStandardError }
525
530
  end
526
- described_class.override(tries: 1)
527
531
 
528
- expect { described_class.with_context(:api) { increment_tries_with_exception(NonStandardError) } }
529
- .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
+
530
537
  expect(@tries).to eq(1)
531
538
  end
532
539
 
533
540
  it "combines local options with override-only contexts" do
534
- 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
535
548
 
536
- expect do
537
- described_class.with_context(:api, on: NonStandardError) do
538
- increment_tries_with_exception(NonStandardError)
539
- end
540
- end.to raise_error(NonStandardError)
541
549
  expect(@tries).to eq(1)
542
550
  end
543
551
 
@@ -546,9 +554,10 @@ describe Retriable do
546
554
  c.contexts[:api] = { tries: 1 }
547
555
  end
548
556
 
549
- described_class.override(tries: 1)
557
+ described_class.with_override(tries: 1) do
558
+ described_class.with_context(:api) { increment_tries }
559
+ end
550
560
 
551
- described_class.with_context(:api) { increment_tries }
552
561
  expect(@tries).to eq(1)
553
562
  end
554
563
 
@@ -556,9 +565,10 @@ describe Retriable do
556
565
  begin
557
566
  described_class.configure { |c| c.contexts = nil }
558
567
 
559
- 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
560
571
 
561
- described_class.with_context(:api) { increment_tries }
562
572
  expect(@tries).to eq(1)
563
573
  ensure
564
574
  described_class.configure { |c| c.contexts = {} }
@@ -570,9 +580,10 @@ describe Retriable do
570
580
  c.contexts[:api] = { tries: 1 }
571
581
  end
572
582
 
573
- described_class.override(contexts: nil)
583
+ described_class.with_override(contexts: nil) do
584
+ described_class.with_context(:api) { increment_tries }
585
+ end
574
586
 
575
- described_class.with_context(:api) { increment_tries }
576
587
  expect(@tries).to eq(1)
577
588
  end
578
589
 
@@ -581,9 +592,10 @@ describe Retriable do
581
592
  c.contexts[:api] = { tries: 1 }
582
593
  end
583
594
 
584
- described_class.override(contexts: 123)
595
+ described_class.with_override(contexts: 123) do
596
+ described_class.with_context(:api) { increment_tries }
597
+ end
585
598
 
586
- described_class.with_context(:api) { increment_tries }
587
599
  expect(@tries).to eq(1)
588
600
  end
589
601
 
@@ -592,9 +604,10 @@ describe Retriable do
592
604
  c.contexts[:api] = { tries: 2 }
593
605
  end
594
606
 
595
- 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
596
610
 
597
- expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
598
611
  expect(@tries).to eq(2)
599
612
  end
600
613
 
@@ -603,10 +616,10 @@ describe Retriable do
603
616
  c.contexts[:configured] = { tries: 2 }
604
617
  end
605
618
 
606
- described_class.override(contexts: { override_only: { tries: 1 } })
607
-
608
- expect { described_class.with_context(:missing) { increment_tries } }
609
- .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
610
623
  end
611
624
 
612
625
  it "does not snapshot configured contexts when adding override-only contexts" do
@@ -614,37 +627,200 @@ describe Retriable do
614
627
  c.contexts[:api] = { tries: 2 }
615
628
  end
616
629
 
617
- 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
618
634
 
619
- described_class.configure do |c|
620
- c.contexts[:api] = { tries: 5 }
635
+ expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
621
636
  end
622
637
 
623
- expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
624
638
  expect(@tries).to eq(5)
625
639
  end
626
640
 
627
641
  it "raises ArgumentError on invalid override options" do
628
- 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)
629
643
  end
630
644
 
631
645
  it "raises ArgumentError on empty override options" do
632
- 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/)
633
651
  end
634
652
 
635
653
  it "raises ArgumentError on invalid context override options" do
636
- expect { described_class.override(contexts: { api: { does_not_exist: 123 } }) }
654
+ expect { described_class.with_override(contexts: { api: { does_not_exist: 123 } }) { :noop } }
637
655
  .to raise_error(ArgumentError, /does_not_exist is not a valid option/)
638
656
  end
639
657
 
640
- it "does not copy the provided override options" do
641
- opts = { tries: 1 }
642
- 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
662
+
663
+ expect { described_class.retriable(tries: 3) { increment_tries_with_exception } }.to raise_error(StandardError)
664
+ expect(@tries).to eq(3)
665
+ end
643
666
 
644
- opts[:tries] = 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")
645
671
 
646
- expect { described_class.retriable(tries: 10) { increment_tries_with_exception } }.to raise_error(StandardError)
647
- expect(@tries).to eq(2)
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)
648
824
  end
649
825
  end
650
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.1
4
+ version: 3.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jack Chu
@@ -74,6 +74,7 @@ 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