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