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 +4 -4
- data/.github/workflows/main.yml +5 -2
- data/CHANGELOG.md +9 -0
- data/README.md +58 -24
- data/lib/retriable/config.rb +28 -0
- data/lib/retriable/exponential_backoff.rb +14 -0
- data/lib/retriable/validation.rb +41 -0
- data/lib/retriable/version.rb +1 -1
- data/lib/retriable.rb +87 -5
- data/spec/config_spec.rb +9 -0
- data/spec/retriable_spec.rb +289 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b0cb0bb3751f465d22addceebf5466b165871741e5820c7c3291d2e842f6f48a
|
|
4
|
+
data.tar.gz: 8bef0641e9c3b0e39c04d79ffcbdaf4542790264ce6648f6540db933cfddbc54
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6c4e07023a30aa934d5397717344c480fb6af8dd56349fcbcb9b85b9ba5539a3f16d2b16a30cbb3682e4140a04f3ace9ecbf0602e68bc4a1a5c805230a031de6
|
|
7
|
+
data.tar.gz: 4272451f1213b81537fd8f7cd85ced8a341b8f8dbf228493a42393b74cee3400e90eb9aa466ec3e404b98eef78b2a42ade5600b49c5d8d9ba7dd9e9b4ae1f444
|
data/.github/workflows/main.yml
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
383
|
+
c.tries = 3
|
|
384
|
+
c.base_interval = 0.5
|
|
385
|
+
c.rand_factor = 0.5
|
|
386
|
+
end
|
|
351
387
|
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
422
|
+
Then in your test environment, you can override both top-level defaults and per-context options:
|
|
390
423
|
|
|
391
424
|
```ruby
|
|
392
|
-
#
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
data/lib/retriable/config.rb
CHANGED
|
@@ -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
|
data/lib/retriable/version.rb
CHANGED
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
|
-
|
|
31
|
+
contexts = available_contexts
|
|
32
|
+
|
|
33
|
+
if !contexts.key?(context_key)
|
|
21
34
|
raise ArgumentError,
|
|
22
|
-
"#{context_key} not found in Retriable
|
|
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(
|
|
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?
|
|
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
|
-
|
|
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
|
data/spec/retriable_spec.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
+
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
|