retriable 4.1.1 → 5.0.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/AGENTS.md +15 -0
- data/CHANGELOG.md +95 -1
- data/Gemfile +3 -1
- data/README.md +82 -46
- data/benchmark/config_publication.rb +79 -0
- data/docs/adr/0001-copy-on-write-config-publication.md +124 -0
- data/docs/agents/domain.md +38 -0
- data/docs/agents/issue-tracker.md +45 -0
- data/docs/agents/triage-labels.md +17 -0
- data/docs/migration.md +84 -0
- data/lib/retriable/config.rb +129 -6
- data/lib/retriable/core_ext/kernel.rb +2 -0
- data/lib/retriable/exponential_backoff.rb +13 -5
- data/lib/retriable/version.rb +1 -1
- data/lib/retriable.rb +107 -17
- data/sig/retriable.rbs +2 -2
- data/spec/config_spec.rb +178 -0
- data/spec/retriable_spec.rb +339 -16
- metadata +9 -2
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Triage Labels
|
|
2
|
+
|
|
3
|
+
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
|
|
4
|
+
|
|
5
|
+
| Label in mattpocock/skills | Label in our tracker | Meaning |
|
|
6
|
+
| -------------------------- | -------------------- | ---------------------------------------- |
|
|
7
|
+
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
|
|
8
|
+
| `needs-info` | `needs-info` | Waiting on reporter for more information |
|
|
9
|
+
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
|
|
10
|
+
| `ready-for-human` | `ready-for-human` | Requires human implementation |
|
|
11
|
+
| `wontfix` | `wontfix` | Will not be actioned |
|
|
12
|
+
|
|
13
|
+
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
|
|
14
|
+
|
|
15
|
+
Edit the right-hand column to match whatever vocabulary you actually use.
|
|
16
|
+
|
|
17
|
+
None of these labels exist in `kamui/retriable` yet. Create one on first use with `gh label create <name>`; the repo's existing labels (`question`, `dependencies`, `ruby`) don't collide with any of them.
|
data/docs/migration.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Migrating Retriable
|
|
2
|
+
|
|
3
|
+
Upgrade guides for Retriable's breaking changes, newest first. See the
|
|
4
|
+
[CHANGELOG](../CHANGELOG.md) for the full history of every release.
|
|
5
|
+
|
|
6
|
+
- [4.x to 5.x](#4x-to-5x)
|
|
7
|
+
- [3.x to 4.0](#3x-to-40)
|
|
8
|
+
|
|
9
|
+
## 4.x to 5.x
|
|
10
|
+
|
|
11
|
+
Retriable 5.0 makes configuration copy-on-write so that concurrent readers see
|
|
12
|
+
one complete configuration. As part of that change, `Retriable.config` returns a
|
|
13
|
+
deeply frozen snapshot. Code that mutates this snapshot directly now raises
|
|
14
|
+
`FrozenError`:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
Retriable.config.sleep_disabled = true # => FrozenError
|
|
18
|
+
Retriable.config.contexts[:api] = {} # => FrozenError
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Move these writes into a `Retriable.configure` block:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
Retriable.configure do |config|
|
|
25
|
+
config.sleep_disabled = true
|
|
26
|
+
config.contexts[:api] = {}
|
|
27
|
+
end
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Check test setup files such as `spec_helper` and `rails_helper`, where direct
|
|
31
|
+
configuration writes are common. Reading `Retriable.config` is unchanged.
|
|
32
|
+
|
|
33
|
+
## 3.x to 4.0
|
|
34
|
+
|
|
35
|
+
### Ruby version
|
|
36
|
+
|
|
37
|
+
Retriable 4.0 requires Ruby 3.2 or later. If your application still runs Ruby
|
|
38
|
+
2.3.0 through 3.1.x, pin Retriable to the 3.8 release line in your Gemfile:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
gem "retriable", "~> 3.8"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### `timeout:` option removed
|
|
45
|
+
|
|
46
|
+
Retriable 4.0 removes the `timeout:` option deprecated in 3.8.0. The option
|
|
47
|
+
called `Timeout.timeout`, which can interrupt code at any line and leave
|
|
48
|
+
non-interrupt-safe libraries in a corrupt state. [Issue #96](https://github.com/kamui/retriable/issues/96)
|
|
49
|
+
has the original bug report.
|
|
50
|
+
|
|
51
|
+
Replace code such as `Retriable.retriable(timeout: 5) { ... }` with one of the
|
|
52
|
+
following approaches.
|
|
53
|
+
|
|
54
|
+
1. Prefer the library's own timeout setting, such as `Net::HTTP#read_timeout`,
|
|
55
|
+
Faraday's `request.timeout`, or a database statement timeout. These settings
|
|
56
|
+
avoid the arbitrary interruption caused by `Timeout.timeout`.
|
|
57
|
+
|
|
58
|
+
2. If the library has no timeout setting, wrap the operation yourself:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
require "timeout"
|
|
62
|
+
|
|
63
|
+
Retriable.retriable do
|
|
64
|
+
Timeout.timeout(5) do
|
|
65
|
+
# code here...
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
This keeps the old behavior, including its risks. `Timeout.timeout` may
|
|
71
|
+
interrupt code while it holds a mutex, file handle, network socket, or other
|
|
72
|
+
internal state. Use it only when the library offers no safer timeout. For more
|
|
73
|
+
detail, read [why Ruby's `Timeout` is dangerous](https://jvns.ca/blog/2015/11/27/why-rubys-timeout-is-dangerous-and-thread-dot-raise-is-terrifying/),
|
|
74
|
+
[Headius on `Thread#raise` and `Timeout`](http://blog.headius.com/2008/02/ruby-threadraise-threadkill-timeoutrb.html),
|
|
75
|
+
[In Ruby, don't use `Timeout`](https://adamhooper.medium.com/in-ruby-dont-use-timeout-77d9d4e5a001), or
|
|
76
|
+
[Timeout: Ruby's most dangerous API](https://www.mikeperham.com/2015/05/08/timeout-rubys-most-dangerous-api/).
|
|
77
|
+
|
|
78
|
+
`Timeout.timeout(5)` applies to each attempt, so every retry gets a new
|
|
79
|
+
five-second limit. Use `max_elapsed_time:` to cap the total time spent across
|
|
80
|
+
all attempts.
|
|
81
|
+
|
|
82
|
+
Passing `timeout:` to `Retriable.retriable` or `Retriable.with_override` now
|
|
83
|
+
raises `ArgumentError`. Setting `timeout` in `Retriable.configure` now raises
|
|
84
|
+
`NoMethodError` because the configuration attribute no longer exists.
|
data/lib/retriable/config.rb
CHANGED
|
@@ -18,16 +18,22 @@ module Retriable
|
|
|
18
18
|
contexts
|
|
19
19
|
]).freeze
|
|
20
20
|
|
|
21
|
+
CONTEXT_ATTRIBUTES = (ATTRIBUTES - %i[contexts]).freeze
|
|
22
|
+
private_constant :CONTEXT_ATTRIBUTES
|
|
23
|
+
|
|
24
|
+
OWNED_CONTAINER_ATTRIBUTES = %i[on intervals contexts].freeze
|
|
25
|
+
private_constant :OWNED_CONTAINER_ATTRIBUTES
|
|
26
|
+
|
|
21
27
|
attr_accessor(*ATTRIBUTES)
|
|
22
28
|
|
|
23
29
|
def initialize(opts = {})
|
|
24
|
-
|
|
30
|
+
defaults = ExponentialBackoff::DEFAULTS
|
|
25
31
|
|
|
26
|
-
@tries =
|
|
27
|
-
@base_interval =
|
|
28
|
-
@max_interval =
|
|
29
|
-
@rand_factor =
|
|
30
|
-
@multiplier =
|
|
32
|
+
@tries = defaults[:tries]
|
|
33
|
+
@base_interval = defaults[:base_interval]
|
|
34
|
+
@max_interval = defaults[:max_interval]
|
|
35
|
+
@rand_factor = defaults[:rand_factor]
|
|
36
|
+
@multiplier = defaults[:multiplier]
|
|
31
37
|
@sleep_disabled = false
|
|
32
38
|
@max_elapsed_time = 900 # 15 min
|
|
33
39
|
@intervals = nil
|
|
@@ -51,6 +57,7 @@ module Retriable
|
|
|
51
57
|
end
|
|
52
58
|
|
|
53
59
|
def validate!
|
|
60
|
+
validate_contexts
|
|
54
61
|
validate_callable(:retry_if, retry_if)
|
|
55
62
|
validate_callable(:on_retry, on_retry)
|
|
56
63
|
validate_callable(:on_give_up, on_give_up)
|
|
@@ -68,8 +75,124 @@ module Retriable
|
|
|
68
75
|
validate_backoff_options
|
|
69
76
|
end
|
|
70
77
|
|
|
78
|
+
# Deep-freezes the containers this Config owns, then itself. Without the deep
|
|
79
|
+
# part a "frozen" Config stays mutable one level down
|
|
80
|
+
# (`config.contexts[:api][:tries] = 1`), which is precisely the corruption a
|
|
81
|
+
# published snapshot exists to rule out. Leaves — procs, exception classes,
|
|
82
|
+
# regexps, scalars — are shared by reference and left untouched.
|
|
83
|
+
#
|
|
84
|
+
# Retriable only ever freezes a #dup it produced itself, so this never
|
|
85
|
+
# freezes a container the caller still holds.
|
|
86
|
+
def freeze
|
|
87
|
+
return self if frozen?
|
|
88
|
+
|
|
89
|
+
OWNED_CONTAINER_ATTRIBUTES.each do |attribute|
|
|
90
|
+
deep_freeze(instance_variable_get(:"@#{attribute}"))
|
|
91
|
+
end
|
|
92
|
+
super
|
|
93
|
+
end
|
|
94
|
+
|
|
71
95
|
private
|
|
72
96
|
|
|
97
|
+
def validate_contexts
|
|
98
|
+
return unless contexts.is_a?(Hash)
|
|
99
|
+
return if contexts.empty?
|
|
100
|
+
|
|
101
|
+
contexts.each_value do |options|
|
|
102
|
+
next unless options.is_a?(Hash)
|
|
103
|
+
|
|
104
|
+
options.each_key do |k|
|
|
105
|
+
next if CONTEXT_ATTRIBUTES.include?(k)
|
|
106
|
+
|
|
107
|
+
raise ArgumentError, "#{k} is not a valid option"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def initialize_copy(other)
|
|
113
|
+
super
|
|
114
|
+
OWNED_CONTAINER_ATTRIBUTES.each do |attribute|
|
|
115
|
+
instance_variable_set(:"@#{attribute}", deep_dup(other.public_send(attribute)))
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Recursively copies the mutable containers (Hash/Array/Set) so a dup is fully
|
|
120
|
+
# isolated from the original, leaving leaves (scalars, procs, exception
|
|
121
|
+
# classes, regexps) shared by reference.
|
|
122
|
+
#
|
|
123
|
+
# Copies start from #dup rather than a fresh literal. Rebuilding into a bare
|
|
124
|
+
# `{}` silently downgrades a Hash subclass to Hash and drops its
|
|
125
|
+
# default/default_proc, so a `contexts` hash with indifferent access would
|
|
126
|
+
# stop resolving string keys after the first #configure.
|
|
127
|
+
#
|
|
128
|
+
# Frozen state is deliberately not carried over: a dup is the mutable working
|
|
129
|
+
# copy that a #configure block mutates, and Retriable re-freezes it on
|
|
130
|
+
# publish.
|
|
131
|
+
#
|
|
132
|
+
# `seen` maps each source container to its copy so a self-referential
|
|
133
|
+
# structure terminates instead of recursing until the stack blows.
|
|
134
|
+
def deep_dup(value, seen = {}.compare_by_identity)
|
|
135
|
+
case value
|
|
136
|
+
when Hash, Array, Set
|
|
137
|
+
return seen[value] if seen.key?(value)
|
|
138
|
+
|
|
139
|
+
copy = value.dup
|
|
140
|
+
seen[value] = copy
|
|
141
|
+
deep_dup_into(value, copy, seen)
|
|
142
|
+
copy
|
|
143
|
+
else value
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def deep_dup_into(value, copy, seen)
|
|
148
|
+
case value
|
|
149
|
+
when Hash then deep_dup_hash(value, copy, seen)
|
|
150
|
+
when Array then value.each_with_index { |val, index| copy[index] = deep_dup(val, seen) }
|
|
151
|
+
when Set then copy.replace(value.map { |val| deep_dup(val, seen) })
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Keys are deliberately left alone. Ruby already dups and freezes an unfrozen
|
|
156
|
+
# String key on assignment, and the supported key types (Symbols for
|
|
157
|
+
# `contexts`, exception classes for `on`) are immutable already.
|
|
158
|
+
#
|
|
159
|
+
# A mutable default value is part of the copied graph, because a shared one
|
|
160
|
+
# would let `config.contexts[:absent] << x` mutate the caller's object. A
|
|
161
|
+
# default_proc stays shared: it is a callable leaf, like every other proc a
|
|
162
|
+
# Config holds.
|
|
163
|
+
def deep_dup_hash(value, copy, seen)
|
|
164
|
+
value.each { |key, val| copy[key] = deep_dup(val, seen) }
|
|
165
|
+
copy.default = deep_dup(value.default, seen) unless value.default_proc
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Freezes exactly what #deep_dup treats as a container, so the two agree on
|
|
169
|
+
# where a Config's mutable surface ends. `seen` guards the same
|
|
170
|
+
# self-referential case.
|
|
171
|
+
def deep_freeze(value, seen = {}.compare_by_identity)
|
|
172
|
+
case value
|
|
173
|
+
when Hash then deep_freeze_hash(value, seen)
|
|
174
|
+
when Array, Set then deep_freeze_collection(value, seen)
|
|
175
|
+
else value
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def deep_freeze_hash(value, seen)
|
|
180
|
+
return value if seen[value]
|
|
181
|
+
|
|
182
|
+
seen[value] = true
|
|
183
|
+
value.each_value { |val| deep_freeze(val, seen) }
|
|
184
|
+
deep_freeze(value.default, seen) unless value.default_proc
|
|
185
|
+
value.freeze
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def deep_freeze_collection(value, seen)
|
|
189
|
+
return value if seen[value]
|
|
190
|
+
|
|
191
|
+
seen[value] = true
|
|
192
|
+
value.each { |val| deep_freeze(val, seen) }
|
|
193
|
+
value.freeze
|
|
194
|
+
end
|
|
195
|
+
|
|
73
196
|
def validate_backoff_options
|
|
74
197
|
validate_non_negative_number(:base_interval, base_interval)
|
|
75
198
|
validate_non_negative_number(:multiplier, multiplier)
|
|
@@ -14,14 +14,22 @@ module Retriable
|
|
|
14
14
|
rand_factor
|
|
15
15
|
].freeze
|
|
16
16
|
|
|
17
|
+
DEFAULTS = {
|
|
18
|
+
tries: 3,
|
|
19
|
+
base_interval: 0.5,
|
|
20
|
+
max_interval: 60,
|
|
21
|
+
rand_factor: 0.5,
|
|
22
|
+
multiplier: 1.5
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
17
25
|
attr_accessor(*ATTRIBUTES)
|
|
18
26
|
|
|
19
27
|
def initialize(opts = {})
|
|
20
|
-
@tries =
|
|
21
|
-
@base_interval =
|
|
22
|
-
@max_interval =
|
|
23
|
-
@rand_factor =
|
|
24
|
-
@multiplier =
|
|
28
|
+
@tries = DEFAULTS[:tries]
|
|
29
|
+
@base_interval = DEFAULTS[:base_interval]
|
|
30
|
+
@max_interval = DEFAULTS[:max_interval]
|
|
31
|
+
@rand_factor = DEFAULTS[:rand_factor]
|
|
32
|
+
@multiplier = DEFAULTS[:multiplier]
|
|
25
33
|
|
|
26
34
|
opts.each do |k, v|
|
|
27
35
|
raise ArgumentError, "#{k} is not a valid option" if !ATTRIBUTES.include?(k)
|
data/lib/retriable/version.rb
CHANGED
data/lib/retriable.rb
CHANGED
|
@@ -12,17 +12,75 @@ module Retriable
|
|
|
12
12
|
# break callers that use fiber-based concurrency.
|
|
13
13
|
OVERRIDE_THREAD_KEY = :retriable_override
|
|
14
14
|
|
|
15
|
+
# True thread-local storage marking the Config this thread is currently
|
|
16
|
+
# building inside #configure. It answers one question — "am I mid-configure?"
|
|
17
|
+
# Which snapshot a given #retriable/#with_context call resolves against is a
|
|
18
|
+
# separate question, answered by passing that snapshot as an argument (see
|
|
19
|
+
# #retriable_with_config). Keeping the two questions on two mechanisms is
|
|
20
|
+
# deliberate: a thread-local would otherwise leak the resolved snapshot across
|
|
21
|
+
# the caller's block and change what `Retriable.config` returns inside it.
|
|
22
|
+
CONFIGURING_THREAD_KEY = :retriable_configuring
|
|
23
|
+
private_constant :CONFIGURING_THREAD_KEY
|
|
24
|
+
|
|
15
25
|
RetryPlan = Struct.new(:max_tries, :interval_for)
|
|
16
26
|
private_constant :RetryPlan
|
|
17
27
|
|
|
28
|
+
# Serializes complete #configure transactions so concurrent read-modify-write
|
|
29
|
+
# swaps cannot drop one another's updates.
|
|
30
|
+
CONFIG_MUTEX = Mutex.new
|
|
31
|
+
private_constant :CONFIG_MUTEX
|
|
32
|
+
|
|
33
|
+
# Guards the single @config reference. Held only for a reference read or the
|
|
34
|
+
# publishing write, never across the #configure block, so a writer blocks a
|
|
35
|
+
# reader for no longer than a pointer swap.
|
|
36
|
+
#
|
|
37
|
+
# One path on every engine. MRI's GVL would make an unsynchronized read safe,
|
|
38
|
+
# but JRuby and TruffleRuby offer no such happens-before guarantee. The common
|
|
39
|
+
# no-options #retriable path uses the snapshot directly, so this mutex has a
|
|
40
|
+
# measurable cost. benchmark/config_publication.rb tracks that cost. Replace
|
|
41
|
+
# this mechanism only when measurements justify a portable alternative.
|
|
42
|
+
CONFIG_PUBLICATION_MUTEX = Mutex.new
|
|
43
|
+
private_constant :CONFIG_PUBLICATION_MUTEX
|
|
44
|
+
|
|
45
|
+
# Eagerly initialized at load time. `require` is serialized in MRI, so this runs
|
|
46
|
+
# exactly once before any thread can reach #config/#configure, closing the
|
|
47
|
+
# `@config ||= Config.new` check-then-act race. Frozen like every snapshot
|
|
48
|
+
# published after it, so reads are immutable from the very first one.
|
|
49
|
+
@config = Config.new.freeze
|
|
50
|
+
|
|
18
51
|
module_function
|
|
19
52
|
|
|
53
|
+
# Copy-on-write: dup the published config, let the caller mutate the copy, then
|
|
54
|
+
# atomically publish it, deeply frozen. Readers therefore always observe a
|
|
55
|
+
# consistent, fully-applied snapshot that nothing can mutate underneath them,
|
|
56
|
+
# and a failed/raising block leaves the old config intact. Does NOT validate
|
|
57
|
+
# (validation stays lazy at #retriable time).
|
|
58
|
+
# Nested calls on the configuring thread share the outer candidate. Only the
|
|
59
|
+
# outermost call takes CONFIG_MUTEX and publishes, so nesting remains safe even
|
|
60
|
+
# though the mutex is not reentrant.
|
|
20
61
|
def configure
|
|
21
|
-
|
|
62
|
+
candidate = configuring_config
|
|
63
|
+
return yield(candidate) if candidate
|
|
64
|
+
|
|
65
|
+
CONFIG_MUTEX.synchronize do
|
|
66
|
+
candidate = config.dup
|
|
67
|
+
Thread.current.thread_variable_set(CONFIGURING_THREAD_KEY, candidate)
|
|
68
|
+
begin
|
|
69
|
+
result = yield(candidate)
|
|
70
|
+
publish_config(candidate)
|
|
71
|
+
result
|
|
72
|
+
ensure
|
|
73
|
+
Thread.current.thread_variable_set(CONFIGURING_THREAD_KEY, nil)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
22
76
|
end
|
|
23
77
|
|
|
78
|
+
# The configuring thread sees its own candidate, still mutable and mid-build.
|
|
79
|
+
# Every other reader sees the last fully published snapshot, which is deeply
|
|
80
|
+
# frozen: mutating it raises FrozenError instead of silently corrupting the
|
|
81
|
+
# config other threads are reading. Use #configure to change configuration.
|
|
24
82
|
def config
|
|
25
|
-
|
|
83
|
+
configuring_config || published_config
|
|
26
84
|
end
|
|
27
85
|
|
|
28
86
|
def with_override(opts = {})
|
|
@@ -41,24 +99,34 @@ module Retriable
|
|
|
41
99
|
end
|
|
42
100
|
|
|
43
101
|
def with_context(context_key, options = {}, &)
|
|
44
|
-
|
|
102
|
+
raise ArgumentError, "with_context requires a block" unless block_given?
|
|
103
|
+
|
|
104
|
+
# Resolve the whole call against one snapshot and one traversal of its
|
|
105
|
+
# contexts. Re-reading `config` here would let a concurrent #configure pass
|
|
106
|
+
# the existence check on the old snapshot while options resolve against the
|
|
107
|
+
# new one, silently dropping the context's retry options.
|
|
108
|
+
config_snapshot = config
|
|
109
|
+
configured_contexts = config_contexts(config_snapshot)
|
|
110
|
+
contexts = configured_contexts.merge(override_contexts)
|
|
45
111
|
|
|
46
112
|
if !contexts.key?(context_key)
|
|
47
113
|
raise ArgumentError,
|
|
48
114
|
"#{context_key} not found in Retriable contexts (including overrides). Available contexts: #{contexts.keys}"
|
|
49
115
|
end
|
|
50
116
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
retriable(context_options_for(context_key, options), &)
|
|
117
|
+
retriable_with_config(config_snapshot, context_options_for(context_key, configured_contexts, options), &)
|
|
54
118
|
end
|
|
55
119
|
|
|
56
120
|
def retriable(opts = {}, &)
|
|
121
|
+
retriable_with_config(config, opts, &)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def retriable_with_config(base_config, opts = {}, &)
|
|
57
125
|
override_config = current_override
|
|
58
126
|
local_config = if opts.empty? && !override_config
|
|
59
|
-
|
|
127
|
+
base_config
|
|
60
128
|
else
|
|
61
|
-
Config.new(apply_override_options(merge_layer(
|
|
129
|
+
Config.new(apply_override_options(merge_layer(base_config.to_h, opts), override_config))
|
|
62
130
|
end
|
|
63
131
|
|
|
64
132
|
# Config is mutable through `configure`, so validate again immediately before use.
|
|
@@ -97,6 +165,9 @@ module Retriable
|
|
|
97
165
|
rescue *exception_list => e
|
|
98
166
|
raise unless retriable_exception?(e, on, exception_list, retry_if)
|
|
99
167
|
|
|
168
|
+
# On the final attempt `interval_for` returns nil (no next retry), and
|
|
169
|
+
# `on_retry` intentionally fires before the give-up check below, so it
|
|
170
|
+
# receives `interval: nil`. See the on_retry/on_give_up README contract.
|
|
100
171
|
interval = interval_for.call(try - 1)
|
|
101
172
|
call_on_retry(on_retry, e, try, elapsed_time.call, interval)
|
|
102
173
|
|
|
@@ -229,12 +300,11 @@ module Retriable
|
|
|
229
300
|
merged
|
|
230
301
|
end
|
|
231
302
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
context_options = config_contexts.fetch(context_key, {})
|
|
303
|
+
# Takes the already-resolved contexts hash rather than the config snapshot, so
|
|
304
|
+
# the snapshot travels exactly one hop (into #retriable_with_config) instead of
|
|
305
|
+
# through every private helper that happens to need a corner of it.
|
|
306
|
+
def context_options_for(context_key, contexts, options)
|
|
307
|
+
context_options = contexts.fetch(context_key, {})
|
|
238
308
|
context_options = {} unless context_options.is_a?(Hash)
|
|
239
309
|
context_options = merge_layer(context_options, options)
|
|
240
310
|
|
|
@@ -244,8 +314,8 @@ module Retriable
|
|
|
244
314
|
apply_override_options(context_options, override_context_options)
|
|
245
315
|
end
|
|
246
316
|
|
|
247
|
-
def config_contexts
|
|
248
|
-
|
|
317
|
+
def config_contexts(config_snapshot)
|
|
318
|
+
config_snapshot.contexts.is_a?(Hash) ? config_snapshot.contexts : {}
|
|
249
319
|
end
|
|
250
320
|
|
|
251
321
|
def override_contexts
|
|
@@ -258,7 +328,28 @@ module Retriable
|
|
|
258
328
|
Thread.current.thread_variable_get(OVERRIDE_THREAD_KEY)
|
|
259
329
|
end
|
|
260
330
|
|
|
331
|
+
def configuring_config
|
|
332
|
+
Thread.current.thread_variable_get(CONFIGURING_THREAD_KEY)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def published_config
|
|
336
|
+
CONFIG_PUBLICATION_MUTEX.synchronize { @config }
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Publishes a deeply frozen deep copy of the candidate. The copy matters: it
|
|
340
|
+
# keeps the freeze off objects the caller still owns, so `c.on = my_array`
|
|
341
|
+
# inside a #configure block never leaves my_array frozen. The mutex covers the
|
|
342
|
+
# reference swap only; the copy and freeze happen outside it.
|
|
343
|
+
def publish_config(candidate)
|
|
344
|
+
snapshot = candidate.dup.freeze
|
|
345
|
+
CONFIG_PUBLICATION_MUTEX.synchronize { @config = snapshot }
|
|
346
|
+
end
|
|
347
|
+
|
|
261
348
|
private_class_method(
|
|
349
|
+
:retriable_with_config,
|
|
350
|
+
:configuring_config,
|
|
351
|
+
:published_config,
|
|
352
|
+
:publish_config,
|
|
262
353
|
:validate_override_options,
|
|
263
354
|
:validate_context_override_options,
|
|
264
355
|
:execute_tries,
|
|
@@ -271,7 +362,6 @@ module Retriable
|
|
|
271
362
|
:hash_exception_match?,
|
|
272
363
|
:apply_override_options,
|
|
273
364
|
:merge_layer,
|
|
274
|
-
:available_contexts,
|
|
275
365
|
:context_options_for,
|
|
276
366
|
:config_contexts,
|
|
277
367
|
:override_contexts,
|
data/sig/retriable.rbs
CHANGED
|
@@ -2,10 +2,10 @@ module Retriable
|
|
|
2
2
|
VERSION: String
|
|
3
3
|
OVERRIDE_THREAD_KEY: Symbol
|
|
4
4
|
|
|
5
|
-
def self.configure: () { (Config) ->
|
|
5
|
+
def self.configure: [Result] () { (Config) -> Result } -> Result
|
|
6
6
|
def self.config: () -> Config
|
|
7
7
|
def self.with_override: (Hash[Symbol, untyped] options) { () -> untyped } -> untyped
|
|
8
|
-
def self.with_context: (Symbol context_key, ?Hash[Symbol, untyped] options)
|
|
8
|
+
def self.with_context: (Symbol context_key, ?Hash[Symbol, untyped] options) { (Integer) -> untyped } -> untyped
|
|
9
9
|
def self.retriable: (?Hash[Symbol, untyped] options) { (Integer) -> untyped } -> untyped
|
|
10
10
|
|
|
11
11
|
class Config
|