retriable 3.4.0 → 3.5.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: 1c6c4929adafa9b223f3fbe0ee530ef8a7d1e764cac8f5605be9ce33e3d1b0cc
4
- data.tar.gz: 5d7013b0d5ce9ab8514a8deab7a32d77b0422cc02ead4aceb5a8c9b5ba28bff1
3
+ metadata.gz: cfd17d793faa48456456036eee71083bf629041cfe6f3f673e3bc92ff0090a88
4
+ data.tar.gz: 27946e72249fce2362c2a374c8f352dd27bd8edd4ee3deea61c80e4d0da8f5cd
5
5
  SHA512:
6
- metadata.gz: 9a83302cfe426348ae0a35e6410edce33c8985d49289ab06100be7c5d49a586f93a429632814b2129e67b20dde8963eb2f5941bcc59d12f4c1db56ce05fd6784
7
- data.tar.gz: 792c54b8b8805eb11c93d9ac100671b357b91a465424939a5c3995a2a50a8449033a0e94138bdf2510b2a7f89b4dc35787b0c490c53d63c9a0d2b85f044c80a9
6
+ metadata.gz: 00424ca023aa864fd2a36016138d0dfd2a6a9030f64c7dd427bb296908b244847db139fcad208527f3bcb5cac075c5e0545fd5dd994f4fef48a31ef55baf2939
7
+ data.tar.gz: a414ecfe2931a0bf3fb56f831d654c497a5a1b4d37e4dbe1acf1cbd646083f15bbd85271afcf8f5b9b9f6c484c9f4c5b34ef462b35b2ffd7ec4371b50e7769ab
@@ -7,6 +7,9 @@ on:
7
7
  branches: [main]
8
8
  types: [opened, synchronize, reopened]
9
9
 
10
+ permissions:
11
+ contents: read
12
+
10
13
  jobs:
11
14
  ci:
12
15
  # The type of runner that the job will run on
@@ -34,10 +37,10 @@ jobs:
34
37
 
35
38
  steps:
36
39
  # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
37
- - uses: actions/checkout@v6
40
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
38
41
 
39
42
  - name: Setup ruby
40
- uses: ruby/setup-ruby@v1
43
+ uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1
41
44
  with:
42
45
  ruby-version: ${{ matrix.ruby }}
43
46
  bundler-cache: true
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # HEAD
2
2
 
3
+ ## 3.5.0
4
+
5
+ - Fix: Do not count skipped sleep intervals against `max_elapsed_time` when `sleep_disabled` is true.
6
+ - Add `override` and `reset_override` APIs to force retry settings over local call options when needed (for example, test short-circuiting).
7
+
8
+ ## 3.4.1
9
+
10
+ - Fix: Use `Process.clock_gettime(CLOCK_MONOTONIC)` for elapsed time tracking so retry timing is immune to wall-clock adjustments (NTP, manual changes).
11
+ - Fix: Handle `max_elapsed_time: nil` gracefully instead of raising `NoMethodError`.
12
+ - Remove dead `* 1.0` float coercion in `ExponentialBackoff#randomize`.
13
+
3
14
  ## 3.4.0
4
15
 
5
16
  - Add `retry_if` option to support custom retry predicates, including checks against wrapped `exception.cause` values.
data/README.md CHANGED
@@ -32,7 +32,7 @@ require 'retriable'
32
32
  In your Gemfile:
33
33
 
34
34
  ```ruby
35
- gem 'retriable', '~> 3.4'
35
+ gem 'retriable', '~> 3.5'
36
36
  ```
37
37
 
38
38
  ## Usage
@@ -87,7 +87,7 @@ Here are the available options, in some vague order of relevance to most common
87
87
  | **`on_retry`** | `nil` | `Proc` to call after each try is rescued. [Read more](#callbacks). |
88
88
  | **`sleep_disabled`** | `false` | When true, disable exponential backoff and attempt retries immediately. |
89
89
  | **`base_interval`** | `0.5` | The initial interval in seconds between tries. |
90
- | **`max_elapsed_time`** | `900` (15 min) | The maximum amount of total time in seconds that code is allowed to keep being retried. |
90
+ | **`max_elapsed_time`** | `900` (15 min) | The maximum amount of total time in seconds that code is allowed to keep being retried. Set to `nil` to disable the time limit and retry based solely on `tries`. |
91
91
  | **`max_interval`** | `60` | The maximum interval in seconds that any individual retry can reach. |
92
92
  | **`multiplier`** | `1.5` | Each successive interval grows by this factor. A multipler of 1.5 means the next interval will be 1.5x the current interval. |
93
93
  | **`rand_factor`** | `0.5` | The percentage to randomize the next retry interval time. The next interval calculation is `randomized_interval = retry_interval * (random value in range [1 - randomization_factor, 1 + randomization_factor])` |
@@ -142,6 +142,37 @@ Retriable.configure do |c|
142
142
  end
143
143
  ```
144
144
 
145
+ `#configure` sets defaults only. Per-call options passed to `Retriable.retriable` and
146
+ `Retriable.with_context` still take precedence.
147
+
148
+ ### Override
149
+
150
+ If you need to force values globally (including over per-call options), use
151
+ `#override`:
152
+
153
+ ```ruby
154
+ Retriable.override(tries: 1, base_interval: 0)
155
+ ```
156
+
157
+ `#override` precedence:
158
+
159
+ ```
160
+ override > local options > configure defaults
161
+ ```
162
+
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.
166
+
167
+ `#override` stores the provided options directly. Do not mutate the options hash
168
+ or nested values after passing them to `#override`.
169
+
170
+ To clear an override:
171
+
172
+ ```ruby
173
+ Retriable.reset_override
174
+ ```
175
+
145
176
  ### Example Usage
146
177
 
147
178
  This example will only retry on a `Timeout::Error`, retry 3 times and sleep for a full second before each try.
@@ -340,33 +371,33 @@ end
340
371
 
341
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.
342
373
 
343
- You can disable retrying by setting `tries` to 1 in the test environment. If you want to test that the code is retrying an error, you want to [turn off exponential backoff](#turn-off-exponential-backoff).
374
+ If you want to short-circuit retries in tests, including calls that pass local options, use `Retriable.override` and set `tries` to `1`.
344
375
 
345
- Under Rails, you could change your initializer to have different options in test, as follows:
376
+ Under Rails, keep shared defaults in `Retriable.configure` and apply test-only overrides conditionally:
346
377
 
347
378
  ```ruby
348
379
  # config/initializers/retriable.rb
349
380
  Retriable.configure do |c|
350
- # ... default configuration
381
+ c.tries = 3
382
+ c.base_interval = 0.5
383
+ c.rand_factor = 0.5
384
+ end
351
385
 
352
- if Rails.env.test?
353
- c.tries = 1
354
- end
386
+ if Rails.env.test?
387
+ Retriable.override(tries: 1, base_interval: 0, rand_factor: 0)
355
388
  end
356
389
  ```
357
390
 
358
- Note: In this and the following examples, `Retriable.configure` sets a default config, it doesn't override the configuration for the `retriable` method calls. Calling `Retriable.retriable` with options will override the default configuration for that call. So if you have `tries` set to 5 in `Retriable.configure`, but then you call `Retriable.retriable(tries: 3)`, that call will use 3 tries instead of 5. The configuration is basically a default set of options that can be overridden by passing options to the `retriable` method or by using contexts.
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.
359
392
 
360
- Alternately, if you are using RSpec, you could override the Retriable confguration in your `spec_helper`.
393
+ Alternately, if you are using RSpec, you could override the Retriable configuration in your `spec_helper`.
361
394
 
362
395
  ```ruby
363
396
  # spec/spec_helper.rb
364
- Retriable.configure do |c|
365
- c.tries = 1
366
- end
397
+ Retriable.override(tries: 1, base_interval: 0, rand_factor: 0)
367
398
  ```
368
399
 
369
- If you have defined contexts for your configuration, you'll need to change values for each context, because those values take precedence over the default configured value.
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`:
370
401
 
371
402
  For example assuming you have configured a `google_api` context:
372
403
 
@@ -386,20 +417,21 @@ Retriable.configure do |c|
386
417
  end
387
418
  ```
388
419
 
389
- Then in your test environment, you would need to set each context and the default value:
420
+ Then in your test environment, you can override both top-level defaults and per-context options:
390
421
 
391
422
  ```ruby
392
- # spec/spec_helper.rb
393
- Retriable.configure do |c|
394
- c.multiplier = 1.0
395
- c.rand_factor = 0.0
396
- c.base_interval = 0
397
-
398
- c.contexts.keys.each do |context|
399
- c.contexts[context][:tries] = 1
400
- c.contexts[context][:base_interval] = 0
401
- end
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 }
402
427
  end
428
+
429
+ Retriable.override(
430
+ multiplier: 1.0,
431
+ rand_factor: 0.0,
432
+ base_interval: 0,
433
+ contexts: context_overrides,
434
+ )
403
435
  ```
404
436
 
405
437
  ## Credits
@@ -28,7 +28,7 @@ module Retriable
28
28
 
29
29
  def intervals
30
30
  intervals = Array.new(tries) do |iteration|
31
- [base_interval * multiplier**iteration, max_interval].min
31
+ [base_interval * (multiplier**iteration), max_interval].min
32
32
  end
33
33
 
34
34
  return intervals if rand_factor.zero?
@@ -39,7 +39,7 @@ module Retriable
39
39
  private
40
40
 
41
41
  def randomize(interval)
42
- delta = rand_factor * interval * 1.0
42
+ delta = rand_factor * interval.to_f
43
43
  min = interval - delta
44
44
  max = interval + delta
45
45
  rand(min..max)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Retriable
4
- VERSION = "3.4.0"
4
+ VERSION = "3.5.0"
5
5
  end
data/lib/retriable.rb CHANGED
@@ -16,19 +16,36 @@ module Retriable
16
16
  @config ||= Config.new
17
17
  end
18
18
 
19
+ def override(opts = {})
20
+ raise ArgumentError, "empty override options are not allowed; use reset_override instead" if opts.empty?
21
+
22
+ validate_override_options(opts)
23
+ @override_config = opts
24
+ end
25
+
26
+ def reset_override
27
+ @override_config = nil
28
+ end
29
+
19
30
  def with_context(context_key, options = {}, &block)
20
- if !config.contexts.key?(context_key)
31
+ contexts = available_contexts
32
+
33
+ if !contexts.key?(context_key)
21
34
  raise ArgumentError,
22
- "#{context_key} not found in Retriable.config.contexts. Available contexts: #{config.contexts.keys}"
35
+ "#{context_key} not found in Retriable contexts (including overrides). Available contexts: #{contexts.keys}"
23
36
  end
24
37
 
25
38
  return unless block_given?
26
39
 
27
- retriable(config.contexts[context_key].merge(options), &block)
40
+ retriable(context_options_for(context_key, options), &block)
28
41
  end
29
42
 
30
43
  def retriable(opts = {}, &block)
31
- local_config = opts.empty? ? config : Config.new(config.to_h.merge(opts))
44
+ local_config = if opts.empty? && !@override_config
45
+ config
46
+ else
47
+ Config.new(apply_override_options(config.to_h.merge(opts), @override_config))
48
+ end
32
49
 
33
50
  tries = local_config.tries
34
51
  intervals = build_intervals(local_config, tries)
@@ -41,8 +58,8 @@ module Retriable
41
58
 
42
59
  exception_list = on.is_a?(Hash) ? on.keys : on
43
60
  exception_list = [*exception_list]
44
- start_time = Time.now
45
- elapsed_time = -> { Time.now - start_time }
61
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
62
+ elapsed_time = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time }
46
63
 
47
64
  tries = intervals.size + 1
48
65
 
@@ -69,7 +86,8 @@ module Retriable
69
86
  interval = intervals[index]
70
87
  call_on_retry(on_retry, e, try, elapsed_time.call, interval)
71
88
 
72
- raise unless can_retry?(try, tries, elapsed_time.call, interval, max_elapsed_time)
89
+ elapsed_interval = sleep_disabled == true ? 0 : interval
90
+ raise unless can_retry?(try, tries, elapsed_time.call, elapsed_interval, max_elapsed_time)
73
91
 
74
92
  sleep interval if sleep_disabled != true
75
93
  end
@@ -101,7 +119,10 @@ module Retriable
101
119
  end
102
120
 
103
121
  def can_retry?(try, tries, elapsed_time, interval, max_elapsed_time)
104
- try < tries && (elapsed_time + interval) <= max_elapsed_time
122
+ return false unless try < tries
123
+ return true if max_elapsed_time.nil?
124
+
125
+ (elapsed_time + interval) <= max_elapsed_time
105
126
  end
106
127
 
107
128
  # When `on` is a Hash, we need to verify the exception matches a pattern.
@@ -125,7 +146,63 @@ module Retriable
125
146
  end
126
147
  end
127
148
 
149
+ def validate_override_options(opts)
150
+ opts.each_key do |k|
151
+ raise ArgumentError, "#{k} is not a valid option" unless Config::ATTRIBUTES.include?(k)
152
+ end
153
+
154
+ contexts = opts[:contexts]
155
+ return unless contexts.is_a?(Hash)
156
+
157
+ contexts.each_value do |context_options|
158
+ validate_context_override_options(context_options)
159
+ end
160
+ end
161
+
162
+ def validate_context_override_options(context_options)
163
+ return unless context_options.is_a?(Hash)
164
+
165
+ context_attributes = Config::ATTRIBUTES - [:contexts]
166
+ context_options.each_key do |k|
167
+ raise ArgumentError, "#{k} is not a valid option" unless context_attributes.include?(k)
168
+ end
169
+ end
170
+
171
+ def apply_override_options(options, overrides)
172
+ return options unless overrides
173
+
174
+ options = options.merge(overrides)
175
+ options[:intervals] = nil if overrides.key?(:tries) && !overrides.key?(:intervals)
176
+ options
177
+ end
178
+
179
+ def available_contexts
180
+ config_contexts.merge(override_contexts)
181
+ end
182
+
183
+ def context_options_for(context_key, options)
184
+ context_options = config_contexts.fetch(context_key, {})
185
+ context_options = {} unless context_options.is_a?(Hash)
186
+ context_options = context_options.merge(options)
187
+
188
+ override_context_options = override_contexts[context_key]
189
+ return context_options unless override_context_options.is_a?(Hash)
190
+
191
+ apply_override_options(context_options, override_context_options)
192
+ end
193
+
194
+ def config_contexts
195
+ config.contexts.is_a?(Hash) ? config.contexts : {}
196
+ end
197
+
198
+ def override_contexts
199
+ contexts = @override_config && @override_config[:contexts]
200
+ contexts.is_a?(Hash) ? contexts : {}
201
+ end
202
+
128
203
  private_class_method(
204
+ :validate_override_options,
205
+ :validate_context_override_options,
129
206
  :execute_tries,
130
207
  :build_intervals,
131
208
  :call_with_timeout,
@@ -133,5 +210,10 @@ module Retriable
133
210
  :can_retry?,
134
211
  :retriable_exception?,
135
212
  :hash_exception_match?,
213
+ :apply_override_options,
214
+ :available_contexts,
215
+ :context_options_for,
216
+ :config_contexts,
217
+ :override_contexts,
136
218
  )
137
219
  end
@@ -1,11 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rbconfig"
4
+
3
5
  describe Retriable do
4
6
  let(:time_table_handler) do
5
7
  ->(_exception, try, _elapsed_time, next_interval) { @next_interval_table[try] = next_interval }
6
8
  end
7
9
 
8
10
  before(:each) do
11
+ described_class.instance_variable_set(:@config, nil)
12
+ described_class.reset_override
9
13
  described_class.configure { |c| c.sleep_disabled = true }
10
14
  @tries = 0
11
15
  @next_interval_table = {}
@@ -23,7 +27,9 @@ describe Retriable do
23
27
 
24
28
  context "global scope extension" do
25
29
  it "cannot be called in the global scope without requiring the core_ext/kernel" do
26
- expect { retriable { puts "should raise NoMethodError" } }.to raise_error(NoMethodError)
30
+ script = "require 'retriable'; begin; retriable {}; rescue NoMethodError; exit 0; end; exit 1"
31
+
32
+ expect(system(RbConfig.ruby, "-Ilib", "-e", script)).to be(true)
27
33
  end
28
34
 
29
35
  it "can be called once the kernel extension is required" do
@@ -35,6 +41,13 @@ describe Retriable do
35
41
  end
36
42
 
37
43
  context "#retriable" do
44
+ it "reuses the singleton config when no local options or overrides are provided" do
45
+ expect(described_class::Config).not_to receive(:new)
46
+
47
+ described_class.retriable { increment_tries }
48
+ expect(@tries).to eq(1)
49
+ end
50
+
38
51
  it "raises a LocalJumpError if not given a block" do
39
52
  expect { described_class.retriable }.to raise_error(LocalJumpError)
40
53
  expect { described_class.retriable(timeout: 2) }.to raise_error(LocalJumpError)
@@ -315,6 +328,50 @@ describe Retriable do
315
328
  expect(@tries).to eq(2)
316
329
  end
317
330
 
331
+ it "does not count skipped sleep intervals against max elapsed time" do
332
+ allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC).and_return(0.0)
333
+
334
+ expect do
335
+ described_class.retriable(tries: 3, base_interval: 1.0, rand_factor: 0.0, max_elapsed_time: 0.1) do
336
+ increment_tries_with_exception
337
+ end
338
+ end.to raise_error(StandardError)
339
+
340
+ expect(@tries).to eq(3)
341
+ end
342
+
343
+ it "retries up to tries limit when max_elapsed_time is nil" do
344
+ expect do
345
+ described_class.retriable(tries: 4, max_elapsed_time: nil) { increment_tries_with_exception }
346
+ end.to raise_error(StandardError)
347
+
348
+ expect(@tries).to eq(4)
349
+ end
350
+
351
+ it "uses monotonic clock for elapsed time tracking" do
352
+ # Stub Process.clock_gettime to return controlled values so we can
353
+ # verify elapsed_time passed to on_retry is derived from the monotonic clock.
354
+ clock_calls = 0
355
+ allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC) do
356
+ value = clock_calls.to_f
357
+ clock_calls += 1
358
+ value
359
+ end
360
+
361
+ elapsed_times = []
362
+ on_retry = ->(_exception, _try, elapsed_time, _next_interval) { elapsed_times << elapsed_time }
363
+
364
+ expect do
365
+ described_class.retriable(tries: 3, on_retry: on_retry) { increment_tries_with_exception }
366
+ end.to raise_error(StandardError)
367
+
368
+ # start_time (call 0) + at least one elapsed_time computation per retry
369
+ expect(clock_calls).to be >= 3
370
+ # elapsed_time values should be positive and non-decreasing
371
+ expect(elapsed_times).to all(be > 0)
372
+ expect(elapsed_times).to eq(elapsed_times.sort)
373
+ end
374
+
318
375
  it "raises ArgumentError on invalid options" do
319
376
  expect { described_class.retriable(does_not_exist: 123) { increment_tries } }.to raise_error(ArgumentError)
320
377
  end
@@ -327,6 +384,8 @@ describe Retriable do
327
384
  with_context
328
385
  configure
329
386
  config
387
+ override
388
+ reset_override
330
389
  ]
331
390
 
332
391
  expect(described_class.singleton_methods(false)).to match_array(public_api_methods)
@@ -337,6 +396,227 @@ describe Retriable do
337
396
  end
338
397
  end
339
398
 
399
+ context "#override" do
400
+ after(:each) do
401
+ described_class.reset_override
402
+ end
403
+
404
+ it "takes precedence over both global config and local options" do
405
+ described_class.configure { |c| c.tries = 2 }
406
+ described_class.override(tries: 1)
407
+
408
+ expect { described_class.retriable(tries: 10) { increment_tries_with_exception } }.to raise_error(StandardError)
409
+ expect(@tries).to eq(1)
410
+ end
411
+
412
+ 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)
418
+
419
+ expect(@tries).to eq(1)
420
+ end
421
+
422
+ it "lets override tries take precedence over context intervals" do
423
+ described_class.configure do |c|
424
+ c.contexts[:api] = { intervals: [0.5, 1.0] }
425
+ end
426
+ described_class.override(tries: 1)
427
+
428
+ expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
429
+ expect(@tries).to eq(1)
430
+ end
431
+
432
+ it "lets override context tries take precedence over context intervals" do
433
+ described_class.configure do |c|
434
+ c.contexts[:api] = { intervals: [0.5, 1.0] }
435
+ end
436
+ described_class.override(contexts: { api: { tries: 1 } })
437
+
438
+ expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
439
+ expect(@tries).to eq(1)
440
+ end
441
+
442
+ 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)
448
+
449
+ expect(@tries).to eq(1)
450
+ end
451
+
452
+ it "can override local intervals with nil to use configured backoff" do
453
+ described_class.configure { |c| c.tries = 3 }
454
+ described_class.override(intervals: nil)
455
+
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)
461
+
462
+ expect(@tries).to eq(3)
463
+ expect(@next_interval_table[1]).to be_between(0.0, 1.0)
464
+ end
465
+
466
+ it "applies override context values after with_context local options" do
467
+ described_class.configure do |c|
468
+ c.contexts[:api] = { tries: 3, base_interval: 1.0 }
469
+ end
470
+
471
+ described_class.override(contexts: { api: { tries: 1 } })
472
+
473
+ described_class.with_context(:api, tries: 10) { increment_tries }
474
+ expect(@tries).to eq(1)
475
+ end
476
+
477
+ it "can define a context only in override config" do
478
+ described_class.override(contexts: { test_only: { tries: 1 } })
479
+
480
+ described_class.with_context(:test_only) { increment_tries }
481
+ expect(@tries).to eq(1)
482
+ end
483
+
484
+ it "does not apply context-only overrides to plain retriable calls" do
485
+ described_class.override(contexts: { api: { tries: 1 } })
486
+
487
+ expect { described_class.retriable(tries: 3) { increment_tries_with_exception } }.to raise_error(StandardError)
488
+ expect(@tries).to eq(3)
489
+ end
490
+
491
+ it "keeps configured context matchers when top-level override values apply" do
492
+ described_class.configure do |c|
493
+ c.contexts[:api] = { tries: 3, on: NonStandardError }
494
+ end
495
+ described_class.override(tries: 1)
496
+
497
+ expect { described_class.with_context(:api) { increment_tries_with_exception(NonStandardError) } }
498
+ .to raise_error(NonStandardError)
499
+ expect(@tries).to eq(1)
500
+ end
501
+
502
+ it "combines local options with override-only contexts" do
503
+ described_class.override(contexts: { api: { tries: 1 } })
504
+
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
+ expect(@tries).to eq(1)
511
+ end
512
+
513
+ it "reuses configured contexts when override does not include contexts" do
514
+ described_class.configure do |c|
515
+ c.contexts[:api] = { tries: 1 }
516
+ end
517
+
518
+ described_class.override(tries: 1)
519
+
520
+ described_class.with_context(:api) { increment_tries }
521
+ expect(@tries).to eq(1)
522
+ end
523
+
524
+ it "treats non-hash configured contexts as empty when override contexts are hash" do
525
+ begin
526
+ described_class.configure { |c| c.contexts = nil }
527
+
528
+ described_class.override(contexts: { api: { tries: 1 } })
529
+
530
+ described_class.with_context(:api) { increment_tries }
531
+ expect(@tries).to eq(1)
532
+ ensure
533
+ described_class.configure { |c| c.contexts = {} }
534
+ end
535
+ end
536
+
537
+ it "ignores nil override contexts values in with_context" do
538
+ described_class.configure do |c|
539
+ c.contexts[:api] = { tries: 1 }
540
+ end
541
+
542
+ described_class.override(contexts: nil)
543
+
544
+ described_class.with_context(:api) { increment_tries }
545
+ expect(@tries).to eq(1)
546
+ end
547
+
548
+ it "ignores non-hash override contexts values in with_context" do
549
+ described_class.configure do |c|
550
+ c.contexts[:api] = { tries: 1 }
551
+ end
552
+
553
+ described_class.override(contexts: 123)
554
+
555
+ described_class.with_context(:api) { increment_tries }
556
+ expect(@tries).to eq(1)
557
+ end
558
+
559
+ it "ignores non-hash per-context override values in with_context" do
560
+ described_class.configure do |c|
561
+ c.contexts[:api] = { tries: 2 }
562
+ end
563
+
564
+ described_class.override(contexts: { api: 123 })
565
+
566
+ expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
567
+ expect(@tries).to eq(2)
568
+ end
569
+
570
+ it "shows merged context keys in with_context missing-context errors" do
571
+ described_class.configure do |c|
572
+ c.contexts[:configured] = { tries: 2 }
573
+ end
574
+
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/)
579
+ end
580
+
581
+ it "does not snapshot configured contexts when adding override-only contexts" do
582
+ described_class.configure do |c|
583
+ c.contexts[:api] = { tries: 2 }
584
+ end
585
+
586
+ described_class.override(contexts: { test_only: { tries: 1 } })
587
+
588
+ described_class.configure do |c|
589
+ c.contexts[:api] = { tries: 5 }
590
+ end
591
+
592
+ expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
593
+ expect(@tries).to eq(5)
594
+ end
595
+
596
+ it "raises ArgumentError on invalid override options" do
597
+ expect { described_class.override(does_not_exist: 123) }.to raise_error(ArgumentError)
598
+ end
599
+
600
+ it "raises ArgumentError on empty override options" do
601
+ expect { described_class.override({}) }.to raise_error(ArgumentError, /empty override/)
602
+ end
603
+
604
+ it "raises ArgumentError on invalid context override options" do
605
+ expect { described_class.override(contexts: { api: { does_not_exist: 123 } }) }
606
+ .to raise_error(ArgumentError, /does_not_exist is not a valid option/)
607
+ end
608
+
609
+ it "does not copy the provided override options" do
610
+ opts = { tries: 1 }
611
+ described_class.override(opts)
612
+
613
+ opts[:tries] = 2
614
+
615
+ expect { described_class.retriable(tries: 10) { increment_tries_with_exception } }.to raise_error(StandardError)
616
+ expect(@tries).to eq(2)
617
+ end
618
+ end
619
+
340
620
  context "#with_context" do
341
621
  let(:api_tries) { 4 }
342
622
 
@@ -384,5 +664,14 @@ describe Retriable do
384
664
  it "raises an ArgumentError when the context isn't found" do
385
665
  expect { described_class.with_context(:wtf) { increment_tries } }.to raise_error(ArgumentError, /wtf not found/)
386
666
  end
667
+
668
+ it "treats non-Hash context values as empty options" do
669
+ described_class.configure do |c|
670
+ c.contexts[:broken] = nil
671
+ end
672
+
673
+ described_class.with_context(:broken) { increment_tries }
674
+ expect(@tries).to eq(1)
675
+ end
387
676
  end
388
677
  end
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.4.0
4
+ version: 3.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jack Chu