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.
@@ -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.
@@ -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
- backoff = ExponentialBackoff.new
30
+ defaults = ExponentialBackoff::DEFAULTS
25
31
 
26
- @tries = backoff.tries
27
- @base_interval = backoff.base_interval
28
- @max_interval = backoff.max_interval
29
- @rand_factor = backoff.rand_factor
30
- @multiplier = backoff.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)
@@ -10,4 +10,6 @@ module Kernel
10
10
  def retriable_with_context(context_key, opts = {}, &)
11
11
  Retriable.with_context(context_key, opts, &)
12
12
  end
13
+
14
+ private :retriable, :retriable_with_context
13
15
  end
@@ -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 = 3
21
- @base_interval = 0.5
22
- @max_interval = 60
23
- @rand_factor = 0.5
24
- @multiplier = 1.5
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Retriable
4
- VERSION = "4.1.1"
4
+ VERSION = "5.0.0"
5
5
  end
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
- yield(config)
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
- @config ||= Config.new
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
- contexts = available_contexts
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
- return unless block_given?
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
- config
127
+ base_config
60
128
  else
61
- Config.new(apply_override_options(merge_layer(config.to_h, opts), override_config))
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
- def available_contexts
233
- config_contexts.merge(override_contexts)
234
- end
235
-
236
- def context_options_for(context_key, options)
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
- config.contexts.is_a?(Hash) ? config.contexts : {}
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) -> void } -> void
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) ?{ (Integer) -> untyped } -> untyped
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