retriable 3.8.0 → 5.0.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.
@@ -28,6 +28,13 @@ module Retriable
28
28
  validate_non_negative_number(name, value)
29
29
  end
30
30
 
31
+ def validate_callable(name, value)
32
+ return unless value # nil/false disable the callback
33
+ return if value.respond_to?(:call)
34
+
35
+ raise ArgumentError, "#{name} must respond to #call or be nil"
36
+ end
37
+
31
38
  def validate_rand_factor
32
39
  return if finite_number?(rand_factor) && rand_factor >= 0 && rand_factor <= 1
33
40
 
@@ -46,7 +53,7 @@ module Retriable
46
53
 
47
54
  # Validates an `on:` value. Acceptable shapes:
48
55
  # - a Class that descends from Exception
49
- # - an Array whose elements are Classes that descend from Exception
56
+ # - an Array or Set whose elements are Classes that descend from Exception
50
57
  # - a Hash whose keys are such Classes and whose values are nil,
51
58
  # a Regexp, or an Array of Regexps
52
59
  #
@@ -58,12 +65,12 @@ module Retriable
58
65
  # Regexp values explicitly.
59
66
  def validate_on(value)
60
67
  case value
61
- when Hash
68
+ in Hash
62
69
  value.each do |klass, pattern|
63
70
  validate_on_class(klass)
64
71
  validate_on_hash_value(klass, pattern)
65
72
  end
66
- when Array
73
+ in Array | Set
67
74
  value.each { |klass| validate_on_class(klass) }
68
75
  else
69
76
  validate_on_class(value)
@@ -79,10 +86,7 @@ module Retriable
79
86
  def validate_on_hash_value(klass, pattern)
80
87
  return if pattern.nil?
81
88
  return if pattern.is_a?(Regexp)
82
- # Ruby 2.3 does not support Enumerable#all?(pattern).
83
- # rubocop:disable Style/PredicateWithKind
84
- return if pattern.is_a?(Array) && pattern.all? { |p| p.is_a?(Regexp) }
85
- # rubocop:enable Style/PredicateWithKind
89
+ return if pattern.is_a?(Array) && pattern.all?(Regexp)
86
90
 
87
91
  raise ArgumentError,
88
92
  "on[#{klass}] must be nil, a Regexp, or an Array of Regexps, got #{pattern.inspect}"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Retriable
4
- VERSION = "3.8.0"
4
+ VERSION = "5.0.1"
5
5
  end
data/lib/retriable.rb CHANGED
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "timeout"
4
3
  require_relative "retriable/config"
5
4
  require_relative "retriable/exponential_backoff"
6
5
  require_relative "retriable/version"
@@ -13,17 +12,75 @@ module Retriable
13
12
  # break callers that use fiber-based concurrency.
14
13
  OVERRIDE_THREAD_KEY = :retriable_override
15
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
+
16
25
  RetryPlan = Struct.new(:max_tries, :interval_for)
17
26
  private_constant :RetryPlan
18
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
+
19
51
  module_function
20
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.
21
61
  def configure
22
- 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
23
76
  end
24
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.
25
82
  def config
26
- @config ||= Config.new
83
+ configuring_config || published_config
27
84
  end
28
85
 
29
86
  def with_override(opts = {})
@@ -41,35 +98,44 @@ module Retriable
41
98
  end
42
99
  end
43
100
 
44
- def with_context(context_key, options = {}, &block)
45
- contexts = available_contexts
101
+ def with_context(context_key, options = {}, &)
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)
46
111
 
47
112
  if !contexts.key?(context_key)
48
113
  raise ArgumentError,
49
114
  "#{context_key} not found in Retriable contexts (including overrides). Available contexts: #{contexts.keys}"
50
115
  end
51
116
 
52
- return unless block_given?
117
+ retriable_with_config(config_snapshot, context_options_for(context_key, configured_contexts, options), &)
118
+ end
53
119
 
54
- retriable(context_options_for(context_key, options), &block)
120
+ def retriable(opts = {}, &)
121
+ retriable_with_config(config, opts, &)
55
122
  end
56
123
 
57
- def retriable(opts = {}, &block)
124
+ def retriable_with_config(base_config, opts = {}, &)
58
125
  override_config = current_override
59
126
  local_config = if opts.empty? && !override_config
60
- config
127
+ # A reused config may have been mutated inside configure.
128
+ base_config.validate!
129
+ base_config
61
130
  else
62
- Config.new(apply_override_options(config.to_h.merge(opts), override_config))
131
+ Config.new(apply_override_options(merge_layer(base_config.to_h, opts), override_config))
63
132
  end
64
133
 
65
- # Config is mutable through `configure`, so validate again immediately before use.
66
- local_config.validate!
67
-
68
134
  plan = retry_plan(local_config)
69
- timeout = local_config.timeout
70
135
  on = local_config.on
71
136
  retry_if = local_config.retry_if
72
137
  on_retry = local_config.on_retry
138
+ on_give_up = local_config.on_give_up
73
139
  sleep_disabled = local_config.sleep_disabled
74
140
  max_elapsed_time = local_config.max_elapsed_time
75
141
 
@@ -79,30 +145,39 @@ module Retriable
79
145
  elapsed_time = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time }
80
146
 
81
147
  execute_tries(
82
- max_tries: plan.max_tries, interval_for: plan.interval_for, timeout: timeout,
148
+ max_tries: plan.max_tries, interval_for: plan.interval_for,
83
149
  exception_list: exception_list, on: on, retry_if: retry_if, on_retry: on_retry,
84
- elapsed_time: elapsed_time, max_elapsed_time: max_elapsed_time,
85
- sleep_disabled: sleep_disabled, &block
150
+ on_give_up: on_give_up, elapsed_time: elapsed_time, max_elapsed_time: max_elapsed_time,
151
+ sleep_disabled: sleep_disabled, &
86
152
  )
87
153
  end
88
154
 
89
155
  def execute_tries( # rubocop:disable Metrics/ParameterLists
90
- max_tries:, interval_for:, timeout:, exception_list:,
91
- on:, retry_if:, on_retry:, elapsed_time:, max_elapsed_time:, sleep_disabled:, &block
156
+ max_tries:, interval_for:, exception_list:,
157
+ on:, retry_if:, on_retry:, on_give_up:, elapsed_time:, max_elapsed_time:, sleep_disabled:
92
158
  )
93
159
  try = 0
94
- loop do
160
+ while true # rubocop:disable Style/InfiniteLoop -- Kernel#loop swallows StopIteration.
95
161
  try += 1
96
162
  begin
97
- return call_with_timeout(timeout, try, &block)
163
+ return yield(try)
98
164
  rescue *exception_list => e
99
165
  raise unless retriable_exception?(e, on, exception_list, retry_if)
100
166
 
167
+ # On the final attempt `interval_for` returns nil (no next retry), and
168
+ # `on_retry` intentionally fires before the give-up check below, so it
169
+ # receives `interval: nil`. See the on_retry/on_give_up README contract.
101
170
  interval = interval_for.call(try - 1)
102
171
  call_on_retry(on_retry, e, try, elapsed_time.call, interval)
103
172
 
104
173
  elapsed_interval = sleep_disabled == true ? 0 : interval
105
- raise unless can_retry?(try, max_tries, elapsed_time.call, elapsed_interval, max_elapsed_time)
174
+ # Snapshot elapsed_time once so the stop check and on_give_up see the same value.
175
+ current_elapsed_time = elapsed_time.call
176
+ stop_reason = retry_stop_reason(try, max_tries, current_elapsed_time, elapsed_interval, max_elapsed_time)
177
+ if stop_reason
178
+ call_on_give_up(on_give_up, e, try, current_elapsed_time, interval, stop_reason)
179
+ raise
180
+ end
106
181
 
107
182
  sleep interval if sleep_disabled != true
108
183
  end
@@ -132,23 +207,30 @@ module Retriable
132
207
  ).interval_provider
133
208
  end
134
209
 
135
- def call_with_timeout(timeout, try)
136
- return Timeout.timeout(timeout) { yield(try) } if timeout
137
-
138
- yield(try)
139
- end
140
-
141
210
  def call_on_retry(on_retry, exception, try, elapsed_time, interval)
142
211
  return unless on_retry
143
212
 
144
213
  on_retry.call(exception, try, elapsed_time, interval)
145
214
  end
146
215
 
147
- def can_retry?(try, max_tries, elapsed_time, interval, max_elapsed_time)
148
- return false if max_tries && try >= max_tries
149
- return true if max_elapsed_time.nil?
216
+ def call_on_give_up( # rubocop:disable Metrics/ParameterLists
217
+ on_give_up, exception, try, elapsed_time, interval, reason
218
+ )
219
+ return unless on_give_up
220
+
221
+ on_give_up.call(exception, try, elapsed_time, interval, reason)
222
+ end
223
+
224
+ # `:tries_exhausted` is checked first, but the two conditions can't both hold
225
+ # on the same try in practice: `retry_plan` returns a nil interval whenever
226
+ # `try >= max_tries`, so `(elapsed_time + interval) > max_elapsed_time` is not
227
+ # evaluable on the exhausted-tries try. The early return guards against that
228
+ # nil and also pins precedence in case the plan ever changes.
229
+ def retry_stop_reason(try, max_tries, elapsed_time, interval, max_elapsed_time)
230
+ return :tries_exhausted if max_tries && try >= max_tries
231
+ return nil if max_elapsed_time.nil?
150
232
 
151
- (elapsed_time + interval) <= max_elapsed_time
233
+ :max_elapsed_time if (elapsed_time + interval) > max_elapsed_time
152
234
  end
153
235
 
154
236
  # When `on` is a Hash, we need to verify the exception matches a pattern.
@@ -204,19 +286,26 @@ module Retriable
204
286
  def apply_override_options(options, overrides)
205
287
  return options unless overrides
206
288
 
207
- options = options.merge(overrides)
208
- options[:intervals] = nil if overrides.key?(:tries) && !overrides.key?(:intervals)
209
- options
289
+ merge_layer(options, overrides)
210
290
  end
211
291
 
212
- def available_contexts
213
- config_contexts.merge(override_contexts)
292
+ # Merge a higher-precedence option layer onto a base layer. A higher layer
293
+ # that sets `tries` without `intervals` clears the base layer's inherited
294
+ # `intervals`, so a caller's `tries:` is never silently ignored. When the
295
+ # higher layer supplies its own `intervals`, those win (same-call override).
296
+ def merge_layer(base, higher)
297
+ merged = base.merge(higher)
298
+ merged[:intervals] = nil if higher.key?(:tries) && !higher.key?(:intervals)
299
+ merged
214
300
  end
215
301
 
216
- def context_options_for(context_key, options)
217
- context_options = config_contexts.fetch(context_key, {})
302
+ # Takes the already-resolved contexts hash rather than the config snapshot, so
303
+ # the snapshot travels exactly one hop (into #retriable_with_config) instead of
304
+ # through every private helper that happens to need a corner of it.
305
+ def context_options_for(context_key, contexts, options)
306
+ context_options = contexts.fetch(context_key, {})
218
307
  context_options = {} unless context_options.is_a?(Hash)
219
- context_options = context_options.merge(options)
308
+ context_options = merge_layer(context_options, options)
220
309
 
221
310
  override_context_options = override_contexts[context_key]
222
311
  return context_options unless override_context_options.is_a?(Hash)
@@ -224,8 +313,8 @@ module Retriable
224
313
  apply_override_options(context_options, override_context_options)
225
314
  end
226
315
 
227
- def config_contexts
228
- config.contexts.is_a?(Hash) ? config.contexts : {}
316
+ def config_contexts(config_snapshot)
317
+ config_snapshot.contexts.is_a?(Hash) ? config_snapshot.contexts : {}
229
318
  end
230
319
 
231
320
  def override_contexts
@@ -238,19 +327,40 @@ module Retriable
238
327
  Thread.current.thread_variable_get(OVERRIDE_THREAD_KEY)
239
328
  end
240
329
 
330
+ def configuring_config
331
+ Thread.current.thread_variable_get(CONFIGURING_THREAD_KEY)
332
+ end
333
+
334
+ def published_config
335
+ CONFIG_PUBLICATION_MUTEX.synchronize { @config }
336
+ end
337
+
338
+ # Publishes a deeply frozen deep copy of the candidate. The copy matters: it
339
+ # keeps the freeze off objects the caller still owns, so `c.on = my_array`
340
+ # inside a #configure block never leaves my_array frozen. The mutex covers the
341
+ # reference swap only; the copy and freeze happen outside it.
342
+ def publish_config(candidate)
343
+ snapshot = candidate.dup.freeze
344
+ CONFIG_PUBLICATION_MUTEX.synchronize { @config = snapshot }
345
+ end
346
+
241
347
  private_class_method(
348
+ :retriable_with_config,
349
+ :configuring_config,
350
+ :published_config,
351
+ :publish_config,
242
352
  :validate_override_options,
243
353
  :validate_context_override_options,
244
354
  :execute_tries,
245
355
  :retry_plan,
246
356
  :interval_provider,
247
- :call_with_timeout,
248
357
  :call_on_retry,
249
- :can_retry?,
358
+ :call_on_give_up,
359
+ :retry_stop_reason,
250
360
  :retriable_exception?,
251
361
  :hash_exception_match?,
252
362
  :apply_override_options,
253
- :available_contexts,
363
+ :merge_layer,
254
364
  :context_options_for,
255
365
  :config_contexts,
256
366
  :override_contexts,
data/retriable.gemspec CHANGED
@@ -15,15 +15,10 @@ Gem::Specification.new do |spec|
15
15
  "APIs/services or file system calls."
16
16
  spec.homepage = "https://github.com/kamui/retriable"
17
17
  spec.license = "MIT"
18
+ spec.metadata["rubygems_mfa_required"] = "true"
18
19
 
19
20
  spec.files = `git ls-files -z`.split("\x0")
20
- spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
21
21
  spec.require_paths = ["lib"]
22
22
 
23
- spec.required_ruby_version = ">= 2.3.0"
24
-
25
- spec.add_development_dependency "bundler"
26
- spec.add_development_dependency "rspec", "~> 3"
27
-
28
- spec.add_development_dependency "listen", "~> 3.1"
23
+ spec.required_ruby_version = ">= 3.2"
29
24
  end
data/sig/retriable.rbs CHANGED
@@ -1,4 +1,32 @@
1
1
  module Retriable
2
2
  VERSION: String
3
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
3
+ OVERRIDE_THREAD_KEY: Symbol
4
+
5
+ def self.configure: [Result] () { (Config) -> Result } -> Result
6
+ def self.config: () -> Config
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
9
+ def self.retriable: (?Hash[Symbol, untyped] options) { (Integer) -> untyped } -> untyped
10
+
11
+ class Config
12
+ ATTRIBUTES: Array[Symbol]
13
+
14
+ attr_accessor tries: Numeric
15
+ attr_accessor base_interval: Numeric
16
+ attr_accessor max_interval: Numeric
17
+ attr_accessor rand_factor: Numeric
18
+ attr_accessor multiplier: Numeric
19
+ attr_accessor sleep_disabled: bool
20
+ attr_accessor max_elapsed_time: Numeric?
21
+ attr_accessor intervals: Array[Numeric]?
22
+ attr_accessor on: untyped
23
+ attr_accessor retry_if: untyped
24
+ attr_accessor on_retry: untyped
25
+ attr_accessor on_give_up: untyped
26
+ attr_accessor contexts: Hash[Symbol, untyped]
27
+
28
+ def initialize: (?Hash[Symbol, untyped] opts) -> void
29
+ def to_h: () -> Hash[Symbol, untyped]
30
+ def validate!: () -> void
31
+ end
4
32
  end