retriable 3.4.1 → 3.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 86086d1d868b9f609e96d2b5e8e5f6e5910a9822b7c21dd86a4014a12105d7f3
4
- data.tar.gz: a7324a228f6b0565428cb10afe6ecf3a98b7a6984ced4f83c32595f97767b09b
3
+ metadata.gz: b0cb0bb3751f465d22addceebf5466b165871741e5820c7c3291d2e842f6f48a
4
+ data.tar.gz: 8bef0641e9c3b0e39c04d79ffcbdaf4542790264ce6648f6540db933cfddbc54
5
5
  SHA512:
6
- metadata.gz: fc0f71e125a40e52fdb8e4cb99b71b066fc84a067e9011b166a14d08a9f98f20a15a32402818705172275ea8b0a0f814963265eabba9fcae6d7ac40cdfc9e520
7
- data.tar.gz: d13f82d53fdc1c2be693a056fd4d164cddc4a7b34096c6c7367213f899881687854a3df5a20a07ad3678c3aa257297bd42a68cb36e9cf0718a2fa8be54a3bd56
6
+ metadata.gz: 6c4e07023a30aa934d5397717344c480fb6af8dd56349fcbcb9b85b9ba5539a3f16d2b16a30cbb3682e4140a04f3ace9ecbf0602e68bc4a1a5c805230a031de6
7
+ data.tar.gz: 4272451f1213b81537fd8f7cd85ced8a341b8f8dbf228493a42393b74cee3400e90eb9aa466ec3e404b98eef78b2a42ade5600b49c5d8d9ba7dd9e9b4ae1f444
@@ -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,14 @@
1
1
  # HEAD
2
2
 
3
+ ## 3.5.1
4
+
5
+ - 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.
6
+
7
+ ## 3.5.0
8
+
9
+ - Fix: Do not count skipped sleep intervals against `max_elapsed_time` when `sleep_disabled` is true.
10
+ - Add `override` and `reset_override` APIs to force retry settings over local call options when needed (for example, test short-circuiting).
11
+
3
12
  ## 3.4.1
4
13
 
5
14
  - Fix: Use `Process.clock_gettime(CLOCK_MONOTONIC)` for elapsed time tracking so retry timing is immune to wall-clock adjustments (NTP, manual changes).
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
@@ -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:
@@ -142,6 +144,37 @@ Retriable.configure do |c|
142
144
  end
143
145
  ```
144
146
 
147
+ `#configure` sets defaults only. Per-call options passed to `Retriable.retriable` and
148
+ `Retriable.with_context` still take precedence.
149
+
150
+ ### Override
151
+
152
+ If you need to force values globally (including over per-call options), use
153
+ `#override`:
154
+
155
+ ```ruby
156
+ Retriable.override(tries: 1, base_interval: 0)
157
+ ```
158
+
159
+ `#override` precedence:
160
+
161
+ ```
162
+ override > local options > configure defaults
163
+ ```
164
+
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.
168
+
169
+ `#override` stores the provided options directly. Do not mutate the options hash
170
+ or nested values after passing them to `#override`.
171
+
172
+ To clear an override:
173
+
174
+ ```ruby
175
+ Retriable.reset_override
176
+ ```
177
+
145
178
  ### Example Usage
146
179
 
147
180
  This example will only retry on a `Timeout::Error`, retry 3 times and sleep for a full second before each try.
@@ -340,33 +373,33 @@ end
340
373
 
341
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.
342
375
 
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).
376
+ If you want to short-circuit retries in tests, including calls that pass local options, use `Retriable.override` and set `tries` to `1`.
344
377
 
345
- Under Rails, you could change your initializer to have different options in test, as follows:
378
+ Under Rails, keep shared defaults in `Retriable.configure` and apply test-only overrides conditionally:
346
379
 
347
380
  ```ruby
348
381
  # config/initializers/retriable.rb
349
382
  Retriable.configure do |c|
350
- # ... default configuration
383
+ c.tries = 3
384
+ c.base_interval = 0.5
385
+ c.rand_factor = 0.5
386
+ end
351
387
 
352
- if Rails.env.test?
353
- c.tries = 1
354
- end
388
+ if Rails.env.test?
389
+ Retriable.override(tries: 1, base_interval: 0, rand_factor: 0)
355
390
  end
356
391
  ```
357
392
 
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.
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.
359
394
 
360
- Alternately, if you are using RSpec, you could override the Retriable confguration in your `spec_helper`.
395
+ Alternately, if you are using RSpec, you could override the Retriable configuration in your `spec_helper`.
361
396
 
362
397
  ```ruby
363
398
  # spec/spec_helper.rb
364
- Retriable.configure do |c|
365
- c.tries = 1
366
- end
399
+ Retriable.override(tries: 1, base_interval: 0, rand_factor: 0)
367
400
  ```
368
401
 
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.
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`:
370
403
 
371
404
  For example assuming you have configured a `google_api` context:
372
405
 
@@ -386,20 +419,21 @@ Retriable.configure do |c|
386
419
  end
387
420
  ```
388
421
 
389
- Then in your test environment, you would need to set each context and the default value:
422
+ Then in your test environment, you can override both top-level defaults and per-context options:
390
423
 
391
424
  ```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
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 }
402
429
  end
430
+
431
+ Retriable.override(
432
+ multiplier: 1.0,
433
+ rand_factor: 0.0,
434
+ base_interval: 0,
435
+ contexts: context_overrides,
436
+ )
403
437
  ```
404
438
 
405
439
  ## Credits
@@ -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.4.1"
4
+ VERSION = "3.5.1"
5
5
  end
data/lib/retriable.rb CHANGED
@@ -16,19 +16,39 @@ 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
49
+
50
+ # Config is mutable through `configure`, so validate again immediately before use.
51
+ local_config.validate!
32
52
 
33
53
  tries = local_config.tries
34
54
  intervals = build_intervals(local_config, tries)
@@ -69,7 +89,8 @@ module Retriable
69
89
  interval = intervals[index]
70
90
  call_on_retry(on_retry, e, try, elapsed_time.call, interval)
71
91
 
72
- raise unless can_retry?(try, tries, elapsed_time.call, interval, max_elapsed_time)
92
+ elapsed_interval = sleep_disabled == true ? 0 : interval
93
+ raise unless can_retry?(try, tries, elapsed_time.call, elapsed_interval, max_elapsed_time)
73
94
 
74
95
  sleep interval if sleep_disabled != true
75
96
  end
@@ -128,7 +149,63 @@ module Retriable
128
149
  end
129
150
  end
130
151
 
152
+ def validate_override_options(opts)
153
+ opts.each_key do |k|
154
+ raise ArgumentError, "#{k} is not a valid option" unless Config::ATTRIBUTES.include?(k)
155
+ end
156
+
157
+ contexts = opts[:contexts]
158
+ return unless contexts.is_a?(Hash)
159
+
160
+ contexts.each_value do |context_options|
161
+ validate_context_override_options(context_options)
162
+ end
163
+ end
164
+
165
+ def validate_context_override_options(context_options)
166
+ return unless context_options.is_a?(Hash)
167
+
168
+ context_attributes = Config::ATTRIBUTES - [:contexts]
169
+ context_options.each_key do |k|
170
+ raise ArgumentError, "#{k} is not a valid option" unless context_attributes.include?(k)
171
+ end
172
+ end
173
+
174
+ def apply_override_options(options, overrides)
175
+ return options unless overrides
176
+
177
+ options = options.merge(overrides)
178
+ options[:intervals] = nil if overrides.key?(:tries) && !overrides.key?(:intervals)
179
+ options
180
+ end
181
+
182
+ def available_contexts
183
+ config_contexts.merge(override_contexts)
184
+ end
185
+
186
+ def context_options_for(context_key, options)
187
+ context_options = config_contexts.fetch(context_key, {})
188
+ context_options = {} unless context_options.is_a?(Hash)
189
+ context_options = context_options.merge(options)
190
+
191
+ override_context_options = override_contexts[context_key]
192
+ return context_options unless override_context_options.is_a?(Hash)
193
+
194
+ apply_override_options(context_options, override_context_options)
195
+ end
196
+
197
+ def config_contexts
198
+ config.contexts.is_a?(Hash) ? config.contexts : {}
199
+ end
200
+
201
+ def override_contexts
202
+ contexts = @override_config && @override_config[:contexts]
203
+ contexts.is_a?(Hash) ? contexts : {}
204
+ end
205
+
131
206
  private_class_method(
207
+ :validate_override_options,
208
+ :validate_context_override_options,
132
209
  :execute_tries,
133
210
  :build_intervals,
134
211
  :call_with_timeout,
@@ -136,5 +213,10 @@ module Retriable
136
213
  :can_retry?,
137
214
  :retriable_exception?,
138
215
  :hash_exception_match?,
216
+ :apply_override_options,
217
+ :available_contexts,
218
+ :context_options_for,
219
+ :config_contexts,
220
+ :override_contexts,
139
221
  )
140
222
  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
@@ -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,18 @@ 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
+
318
343
  it "retries up to tries limit when max_elapsed_time is nil" do
319
344
  expect do
320
345
  described_class.retriable(tries: 4, max_elapsed_time: nil) { increment_tries_with_exception }
@@ -350,6 +375,37 @@ describe Retriable do
350
375
  it "raises ArgumentError on invalid options" do
351
376
  expect { described_class.retriable(does_not_exist: 123) { increment_tries } }.to raise_error(ArgumentError)
352
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
353
409
  end
354
410
 
355
411
  context "#configure" do
@@ -359,6 +415,8 @@ describe Retriable do
359
415
  with_context
360
416
  configure
361
417
  config
418
+ override
419
+ reset_override
362
420
  ]
363
421
 
364
422
  expect(described_class.singleton_methods(false)).to match_array(public_api_methods)
@@ -369,6 +427,227 @@ describe Retriable do
369
427
  end
370
428
  end
371
429
 
430
+ context "#override" do
431
+ after(:each) do
432
+ described_class.reset_override
433
+ end
434
+
435
+ it "takes precedence over both global config and local options" do
436
+ described_class.configure { |c| c.tries = 2 }
437
+ described_class.override(tries: 1)
438
+
439
+ expect { described_class.retriable(tries: 10) { increment_tries_with_exception } }.to raise_error(StandardError)
440
+ expect(@tries).to eq(1)
441
+ end
442
+
443
+ 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)
449
+
450
+ expect(@tries).to eq(1)
451
+ end
452
+
453
+ it "lets override tries take precedence over context intervals" do
454
+ described_class.configure do |c|
455
+ c.contexts[:api] = { intervals: [0.5, 1.0] }
456
+ end
457
+ described_class.override(tries: 1)
458
+
459
+ expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
460
+ expect(@tries).to eq(1)
461
+ end
462
+
463
+ it "lets override context tries take precedence over context intervals" do
464
+ described_class.configure do |c|
465
+ c.contexts[:api] = { intervals: [0.5, 1.0] }
466
+ end
467
+ described_class.override(contexts: { api: { tries: 1 } })
468
+
469
+ expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
470
+ expect(@tries).to eq(1)
471
+ end
472
+
473
+ 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)
479
+
480
+ expect(@tries).to eq(1)
481
+ end
482
+
483
+ it "can override local intervals with nil to use configured backoff" do
484
+ described_class.configure { |c| c.tries = 3 }
485
+ described_class.override(intervals: nil)
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)
492
+
493
+ expect(@tries).to eq(3)
494
+ expect(@next_interval_table[1]).to be_between(0.0, 1.0)
495
+ end
496
+
497
+ it "applies override context values after with_context local options" do
498
+ described_class.configure do |c|
499
+ c.contexts[:api] = { tries: 3, base_interval: 1.0 }
500
+ end
501
+
502
+ described_class.override(contexts: { api: { tries: 1 } })
503
+
504
+ described_class.with_context(:api, tries: 10) { increment_tries }
505
+ expect(@tries).to eq(1)
506
+ end
507
+
508
+ it "can define a context only in override config" do
509
+ described_class.override(contexts: { test_only: { tries: 1 } })
510
+
511
+ described_class.with_context(:test_only) { increment_tries }
512
+ expect(@tries).to eq(1)
513
+ end
514
+
515
+ it "does not apply context-only overrides to plain retriable calls" do
516
+ described_class.override(contexts: { api: { tries: 1 } })
517
+
518
+ expect { described_class.retriable(tries: 3) { increment_tries_with_exception } }.to raise_error(StandardError)
519
+ expect(@tries).to eq(3)
520
+ end
521
+
522
+ it "keeps configured context matchers when top-level override values apply" do
523
+ described_class.configure do |c|
524
+ c.contexts[:api] = { tries: 3, on: NonStandardError }
525
+ end
526
+ described_class.override(tries: 1)
527
+
528
+ expect { described_class.with_context(:api) { increment_tries_with_exception(NonStandardError) } }
529
+ .to raise_error(NonStandardError)
530
+ expect(@tries).to eq(1)
531
+ end
532
+
533
+ it "combines local options with override-only contexts" do
534
+ described_class.override(contexts: { api: { tries: 1 } })
535
+
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
+ expect(@tries).to eq(1)
542
+ end
543
+
544
+ it "reuses configured contexts when override does not include contexts" do
545
+ described_class.configure do |c|
546
+ c.contexts[:api] = { tries: 1 }
547
+ end
548
+
549
+ described_class.override(tries: 1)
550
+
551
+ described_class.with_context(:api) { increment_tries }
552
+ expect(@tries).to eq(1)
553
+ end
554
+
555
+ it "treats non-hash configured contexts as empty when override contexts are hash" do
556
+ begin
557
+ described_class.configure { |c| c.contexts = nil }
558
+
559
+ described_class.override(contexts: { api: { tries: 1 } })
560
+
561
+ described_class.with_context(:api) { increment_tries }
562
+ expect(@tries).to eq(1)
563
+ ensure
564
+ described_class.configure { |c| c.contexts = {} }
565
+ end
566
+ end
567
+
568
+ it "ignores nil override contexts values in with_context" do
569
+ described_class.configure do |c|
570
+ c.contexts[:api] = { tries: 1 }
571
+ end
572
+
573
+ described_class.override(contexts: nil)
574
+
575
+ described_class.with_context(:api) { increment_tries }
576
+ expect(@tries).to eq(1)
577
+ end
578
+
579
+ it "ignores non-hash override contexts values in with_context" do
580
+ described_class.configure do |c|
581
+ c.contexts[:api] = { tries: 1 }
582
+ end
583
+
584
+ described_class.override(contexts: 123)
585
+
586
+ described_class.with_context(:api) { increment_tries }
587
+ expect(@tries).to eq(1)
588
+ end
589
+
590
+ it "ignores non-hash per-context override values in with_context" do
591
+ described_class.configure do |c|
592
+ c.contexts[:api] = { tries: 2 }
593
+ end
594
+
595
+ described_class.override(contexts: { api: 123 })
596
+
597
+ expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
598
+ expect(@tries).to eq(2)
599
+ end
600
+
601
+ it "shows merged context keys in with_context missing-context errors" do
602
+ described_class.configure do |c|
603
+ c.contexts[:configured] = { tries: 2 }
604
+ end
605
+
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/)
610
+ end
611
+
612
+ it "does not snapshot configured contexts when adding override-only contexts" do
613
+ described_class.configure do |c|
614
+ c.contexts[:api] = { tries: 2 }
615
+ end
616
+
617
+ described_class.override(contexts: { test_only: { tries: 1 } })
618
+
619
+ described_class.configure do |c|
620
+ c.contexts[:api] = { tries: 5 }
621
+ end
622
+
623
+ expect { described_class.with_context(:api) { increment_tries_with_exception } }.to raise_error(StandardError)
624
+ expect(@tries).to eq(5)
625
+ end
626
+
627
+ it "raises ArgumentError on invalid override options" do
628
+ expect { described_class.override(does_not_exist: 123) }.to raise_error(ArgumentError)
629
+ end
630
+
631
+ it "raises ArgumentError on empty override options" do
632
+ expect { described_class.override({}) }.to raise_error(ArgumentError, /empty override/)
633
+ end
634
+
635
+ it "raises ArgumentError on invalid context override options" do
636
+ expect { described_class.override(contexts: { api: { does_not_exist: 123 } }) }
637
+ .to raise_error(ArgumentError, /does_not_exist is not a valid option/)
638
+ end
639
+
640
+ it "does not copy the provided override options" do
641
+ opts = { tries: 1 }
642
+ described_class.override(opts)
643
+
644
+ opts[:tries] = 2
645
+
646
+ expect { described_class.retriable(tries: 10) { increment_tries_with_exception } }.to raise_error(StandardError)
647
+ expect(@tries).to eq(2)
648
+ end
649
+ end
650
+
372
651
  context "#with_context" do
373
652
  let(:api_tries) { 4 }
374
653
 
@@ -416,5 +695,14 @@ describe Retriable do
416
695
  it "raises an ArgumentError when the context isn't found" do
417
696
  expect { described_class.with_context(:wtf) { increment_tries } }.to raise_error(ArgumentError, /wtf not found/)
418
697
  end
698
+
699
+ it "treats non-Hash context values as empty options" do
700
+ described_class.configure do |c|
701
+ c.contexts[:broken] = nil
702
+ end
703
+
704
+ described_class.with_context(:broken) { increment_tries }
705
+ expect(@tries).to eq(1)
706
+ end
419
707
  end
420
708
  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.1
4
+ version: 3.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jack Chu
@@ -78,6 +78,7 @@ files:
78
78
  - lib/retriable/config.rb
79
79
  - lib/retriable/core_ext/kernel.rb
80
80
  - lib/retriable/exponential_backoff.rb
81
+ - lib/retriable/validation.rb
81
82
  - lib/retriable/version.rb
82
83
  - retriable.gemspec
83
84
  - sig/retriable.rbs