retriable 3.4.1 → 3.8.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.
@@ -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
@@ -15,6 +18,19 @@ module Retriable
15
18
  contexts
16
19
  ]).freeze
17
20
 
21
+ TIMEOUT_DEPRECATION_MESSAGE = "NOTE: Retriable's `timeout:` option is deprecated and will be removed in " \
22
+ "Retriable 4.0. It is a thin wrapper around `Timeout.timeout`, which " \
23
+ "can interrupt execution at arbitrary lines and corrupt internal state " \
24
+ "in libraries that are not interrupt-safe. Prefer your library's native " \
25
+ "timeout, or wrap your block in `Timeout.timeout(...)` yourself."
26
+ private_constant :TIMEOUT_DEPRECATION_MESSAGE
27
+
28
+ @timeout_deprecation_warned = false
29
+
30
+ class << self
31
+ attr_accessor :timeout_deprecation_warned
32
+ end
33
+
18
34
  attr_accessor(*ATTRIBUTES)
19
35
 
20
36
  def initialize(opts = {})
@@ -39,6 +55,8 @@ module Retriable
39
55
 
40
56
  instance_variable_set(:"@#{k}", v)
41
57
  end
58
+
59
+ validate!
42
60
  end
43
61
 
44
62
  def to_h
@@ -46,5 +64,90 @@ module Retriable
46
64
  hash[key] = public_send(key)
47
65
  end
48
66
  end
67
+
68
+ def validate!
69
+ warn_timeout_deprecation
70
+ validate_optional_non_negative_number(:timeout, timeout)
71
+ validate_on(on)
72
+ validate_intervals
73
+ if unbounded_tries?(tries)
74
+ validate_unbounded_tries
75
+ else
76
+ validate_optional_non_negative_number(:max_elapsed_time, max_elapsed_time)
77
+ return if intervals
78
+
79
+ validate_positive_integer(:tries, tries)
80
+ end
81
+
82
+ validate_backoff_options
83
+ end
84
+
85
+ private
86
+
87
+ # Emits the `timeout:` deprecation notice at most once per process.
88
+ #
89
+ # On Rubies that support `Kernel#warn(category: :deprecated)` (2.7+), the
90
+ # notice is emitted under the `:deprecated` category, so callers can use the
91
+ # standard controls (`Warning[:deprecated] = false`, `-W:no-deprecated`,
92
+ # `Warning.warn` override) to silence it. On older Rubies the kwarg is not
93
+ # available and we fall back to plain `Kernel.warn`.
94
+ #
95
+ # When the warning is suppressed (either because `Warning[:deprecated]` is
96
+ # false or the runtime has otherwise muted the category), we deliberately
97
+ # leave the once-per-process flag unset so a future call with the category
98
+ # re-enabled still surfaces the notice.
99
+ def warn_timeout_deprecation
100
+ return if timeout.nil?
101
+ return if self.class.timeout_deprecation_warned
102
+
103
+ category_supported = deprecated_warning_category_supported?
104
+ return if category_supported && !deprecated_warnings_enabled?
105
+
106
+ self.class.timeout_deprecation_warned = true
107
+ if category_supported
108
+ Kernel.warn(TIMEOUT_DEPRECATION_MESSAGE, category: :deprecated)
109
+ else
110
+ Kernel.warn(TIMEOUT_DEPRECATION_MESSAGE)
111
+ end
112
+ end
113
+
114
+ def deprecated_warning_category_supported?
115
+ defined?(Warning) && Kernel.method(:warn).parameters.any? { |type, name| type == :key && name == :category }
116
+ end
117
+
118
+ def deprecated_warnings_enabled?
119
+ return true unless defined?(Warning) && Warning.respond_to?(:[])
120
+
121
+ Warning[:deprecated]
122
+ end
123
+
124
+ def validate_backoff_options
125
+ validate_non_negative_number(:base_interval, base_interval)
126
+ validate_non_negative_number(:multiplier, multiplier)
127
+ validate_non_negative_number(:max_interval, max_interval)
128
+ validate_rand_factor
129
+ end
130
+
131
+ def validate_unbounded_tries
132
+ if intervals
133
+ raise ArgumentError,
134
+ "intervals cannot be used with tries: Float::INFINITY"
135
+ end
136
+
137
+ unless finite_number?(max_elapsed_time)
138
+ raise ArgumentError,
139
+ "max_elapsed_time must be a finite number when tries is Float::INFINITY"
140
+ end
141
+
142
+ validate_non_negative_number(:max_elapsed_time, max_elapsed_time)
143
+ end
144
+
145
+ def validate_intervals
146
+ return if intervals.nil?
147
+ raise ArgumentError, "intervals must be an Array" unless intervals.is_a?(Array)
148
+ return if intervals.all? { |interval| finite_number?(interval) && interval >= 0 }
149
+
150
+ raise ArgumentError, "intervals must contain only non-negative numbers"
151
+ end
49
152
  end
50
153
  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,20 +28,42 @@ 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
30
- intervals = Array.new(tries) do |iteration|
31
- [base_interval * (multiplier**iteration), max_interval].min
32
- end
36
+ provider = interval_provider
37
+ Array.new(tries) { |iteration| provider.call(iteration) }
38
+ end
39
+
40
+ def interval_provider
41
+ raw_interval = base_interval
33
42
 
34
- return intervals if rand_factor.zero?
43
+ lambda do |_iteration|
44
+ interval = [raw_interval, max_interval].min
45
+ raw_interval = next_raw_interval(raw_interval)
35
46
 
36
- intervals.map { |i| randomize(i) }
47
+ rand_factor.zero? ? interval : randomize(interval)
48
+ end
37
49
  end
38
50
 
39
51
  private
40
52
 
53
+ def validate!
54
+ validate_non_negative_integer(:tries, tries)
55
+ validate_non_negative_number(:base_interval, base_interval)
56
+ validate_non_negative_number(:multiplier, multiplier)
57
+ validate_non_negative_number(:max_interval, max_interval)
58
+ validate_rand_factor
59
+ end
60
+
61
+ def next_raw_interval(raw_interval)
62
+ return max_interval if multiplier >= 1 && raw_interval >= max_interval
63
+
64
+ raw_interval * multiplier
65
+ end
66
+
41
67
  def randomize(interval)
42
68
  delta = rand_factor * interval.to_f
43
69
  min = interval - delta
@@ -0,0 +1,91 @@
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
+
41
+ def unbounded_tries?(value)
42
+ value.is_a?(Numeric) && value.respond_to?(:infinite?) && value.infinite? == 1
43
+ end
44
+
45
+ module_function :unbounded_tries?
46
+
47
+ # Validates an `on:` value. Acceptable shapes:
48
+ # - a Class that descends from Exception
49
+ # - an Array whose elements are Classes that descend from Exception
50
+ # - a Hash whose keys are such Classes and whose values are nil,
51
+ # a Regexp, or an Array of Regexps
52
+ #
53
+ # Without this validation, callers can pass values like `Object` or
54
+ # `Kernel` and silently retry process-critical exceptions such as
55
+ # SystemExit and Interrupt, because every Exception's ancestor chain
56
+ # includes both. Hash values that are not Regexps (e.g. plain Strings)
57
+ # also silently fail to match in #hash_exception_match?, so we require
58
+ # Regexp values explicitly.
59
+ def validate_on(value)
60
+ case value
61
+ when Hash
62
+ value.each do |klass, pattern|
63
+ validate_on_class(klass)
64
+ validate_on_hash_value(klass, pattern)
65
+ end
66
+ when Array
67
+ value.each { |klass| validate_on_class(klass) }
68
+ else
69
+ validate_on_class(value)
70
+ end
71
+ end
72
+
73
+ def validate_on_class(klass)
74
+ return if klass.is_a?(Class) && klass <= Exception
75
+
76
+ raise ArgumentError, "on must be an Exception class or a collection of Exception classes, got #{klass.inspect}"
77
+ end
78
+
79
+ def validate_on_hash_value(klass, pattern)
80
+ return if pattern.nil?
81
+ 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
86
+
87
+ raise ArgumentError,
88
+ "on[#{klass}] must be nil, a Regexp, or an Array of Regexps, got #{pattern.inspect}"
89
+ end
90
+ end
91
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Retriable
4
- VERSION = "3.4.1"
4
+ VERSION = "3.8.0"
5
5
  end
data/lib/retriable.rb CHANGED
@@ -6,6 +6,16 @@ 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
+
16
+ RetryPlan = Struct.new(:max_tries, :interval_for)
17
+ private_constant :RetryPlan
18
+
9
19
  module_function
10
20
 
11
21
  def configure
@@ -16,22 +26,46 @@ module Retriable
16
26
  @config ||= Config.new
17
27
  end
18
28
 
29
+ def with_override(opts = {})
30
+ raise ArgumentError, "empty override options are not allowed" if opts.empty?
31
+ raise ArgumentError, "with_override requires a block" unless block_given?
32
+
33
+ validate_override_options(opts)
34
+
35
+ previous = Thread.current.thread_variable_get(OVERRIDE_THREAD_KEY)
36
+ Thread.current.thread_variable_set(OVERRIDE_THREAD_KEY, opts)
37
+ begin
38
+ yield
39
+ ensure
40
+ Thread.current.thread_variable_set(OVERRIDE_THREAD_KEY, previous)
41
+ end
42
+ end
43
+
19
44
  def with_context(context_key, options = {}, &block)
20
- if !config.contexts.key?(context_key)
45
+ contexts = available_contexts
46
+
47
+ if !contexts.key?(context_key)
21
48
  raise ArgumentError,
22
- "#{context_key} not found in Retriable.config.contexts. Available contexts: #{config.contexts.keys}"
49
+ "#{context_key} not found in Retriable contexts (including overrides). Available contexts: #{contexts.keys}"
23
50
  end
24
51
 
25
52
  return unless block_given?
26
53
 
27
- retriable(config.contexts[context_key].merge(options), &block)
54
+ retriable(context_options_for(context_key, options), &block)
28
55
  end
29
56
 
30
57
  def retriable(opts = {}, &block)
31
- local_config = opts.empty? ? config : Config.new(config.to_h.merge(opts))
58
+ override_config = current_override
59
+ local_config = if opts.empty? && !override_config
60
+ config
61
+ else
62
+ Config.new(apply_override_options(config.to_h.merge(opts), override_config))
63
+ end
64
+
65
+ # Config is mutable through `configure`, so validate again immediately before use.
66
+ local_config.validate!
32
67
 
33
- tries = local_config.tries
34
- intervals = build_intervals(local_config, tries)
68
+ plan = retry_plan(local_config)
35
69
  timeout = local_config.timeout
36
70
  on = local_config.on
37
71
  retry_if = local_config.retry_if
@@ -44,10 +78,8 @@ module Retriable
44
78
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
45
79
  elapsed_time = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time }
46
80
 
47
- tries = intervals.size + 1
48
-
49
81
  execute_tries(
50
- tries: tries, intervals: intervals, timeout: timeout,
82
+ max_tries: plan.max_tries, interval_for: plan.interval_for, timeout: timeout,
51
83
  exception_list: exception_list, on: on, retry_if: retry_if, on_retry: on_retry,
52
84
  elapsed_time: elapsed_time, max_elapsed_time: max_elapsed_time,
53
85
  sleep_disabled: sleep_disabled, &block
@@ -55,37 +87,49 @@ module Retriable
55
87
  end
56
88
 
57
89
  def execute_tries( # rubocop:disable Metrics/ParameterLists
58
- tries:, intervals:, timeout:, exception_list:,
90
+ max_tries:, interval_for:, timeout:, exception_list:,
59
91
  on:, retry_if:, on_retry:, elapsed_time:, max_elapsed_time:, sleep_disabled:, &block
60
92
  )
61
- tries.times do |index|
62
- try = index + 1
63
-
93
+ try = 0
94
+ loop do
95
+ try += 1
64
96
  begin
65
97
  return call_with_timeout(timeout, try, &block)
66
98
  rescue *exception_list => e
67
99
  raise unless retriable_exception?(e, on, exception_list, retry_if)
68
100
 
69
- interval = intervals[index]
101
+ interval = interval_for.call(try - 1)
70
102
  call_on_retry(on_retry, e, try, elapsed_time.call, interval)
71
103
 
72
- raise unless can_retry?(try, tries, elapsed_time.call, interval, max_elapsed_time)
104
+ elapsed_interval = sleep_disabled == true ? 0 : interval
105
+ raise unless can_retry?(try, max_tries, elapsed_time.call, elapsed_interval, max_elapsed_time)
73
106
 
74
107
  sleep interval if sleep_disabled != true
75
108
  end
76
109
  end
77
110
  end
78
111
 
79
- def build_intervals(local_config, tries)
80
- return local_config.intervals if local_config.intervals
112
+ def retry_plan(local_config)
113
+ return RetryPlan.new(nil, interval_provider(local_config)) if Validation.unbounded_tries?(local_config.tries)
81
114
 
115
+ if local_config.intervals
116
+ intervals = local_config.intervals
117
+ return RetryPlan.new(intervals.size + 1, ->(index) { intervals[index] })
118
+ end
119
+
120
+ max_tries = local_config.tries
121
+ provider = interval_provider(local_config)
122
+
123
+ RetryPlan.new(max_tries, ->(index) { index < max_tries - 1 ? provider.call(index) : nil })
124
+ end
125
+
126
+ def interval_provider(local_config)
82
127
  ExponentialBackoff.new(
83
- tries: tries - 1,
84
128
  base_interval: local_config.base_interval,
85
129
  multiplier: local_config.multiplier,
86
130
  max_interval: local_config.max_interval,
87
131
  rand_factor: local_config.rand_factor,
88
- ).intervals
132
+ ).interval_provider
89
133
  end
90
134
 
91
135
  def call_with_timeout(timeout, try)
@@ -100,8 +144,8 @@ module Retriable
100
144
  on_retry.call(exception, try, elapsed_time, interval)
101
145
  end
102
146
 
103
- def can_retry?(try, tries, elapsed_time, interval, max_elapsed_time)
104
- return false unless try < tries
147
+ def can_retry?(try, max_tries, elapsed_time, interval, max_elapsed_time)
148
+ return false if max_tries && try >= max_tries
105
149
  return true if max_elapsed_time.nil?
106
150
 
107
151
  (elapsed_time + interval) <= max_elapsed_time
@@ -128,13 +172,88 @@ module Retriable
128
172
  end
129
173
  end
130
174
 
175
+ def validate_override_options(opts)
176
+ opts.each_key do |k|
177
+ raise ArgumentError, "#{k} is not a valid option" unless Config::ATTRIBUTES.include?(k)
178
+ end
179
+
180
+ return unless opts.key?(:contexts)
181
+
182
+ contexts = opts[:contexts]
183
+ return if contexts.nil?
184
+
185
+ raise ArgumentError, "contexts must be a Hash or nil, got #{contexts.inspect}" unless contexts.is_a?(Hash)
186
+
187
+ contexts.each do |context_key, context_options|
188
+ validate_context_override_options(context_key, context_options)
189
+ end
190
+ end
191
+
192
+ def validate_context_override_options(context_key, context_options)
193
+ unless context_options.is_a?(Hash)
194
+ raise ArgumentError,
195
+ "contexts[#{context_key.inspect}] must be a Hash, got #{context_options.inspect}"
196
+ end
197
+
198
+ context_attributes = Config::ATTRIBUTES - [:contexts]
199
+ context_options.each_key do |k|
200
+ raise ArgumentError, "#{k} is not a valid option" unless context_attributes.include?(k)
201
+ end
202
+ end
203
+
204
+ def apply_override_options(options, overrides)
205
+ return options unless overrides
206
+
207
+ options = options.merge(overrides)
208
+ options[:intervals] = nil if overrides.key?(:tries) && !overrides.key?(:intervals)
209
+ options
210
+ end
211
+
212
+ def available_contexts
213
+ config_contexts.merge(override_contexts)
214
+ end
215
+
216
+ def context_options_for(context_key, options)
217
+ context_options = config_contexts.fetch(context_key, {})
218
+ context_options = {} unless context_options.is_a?(Hash)
219
+ context_options = context_options.merge(options)
220
+
221
+ override_context_options = override_contexts[context_key]
222
+ return context_options unless override_context_options.is_a?(Hash)
223
+
224
+ apply_override_options(context_options, override_context_options)
225
+ end
226
+
227
+ def config_contexts
228
+ config.contexts.is_a?(Hash) ? config.contexts : {}
229
+ end
230
+
231
+ def override_contexts
232
+ override_config = current_override
233
+ contexts = override_config && override_config[:contexts]
234
+ contexts.is_a?(Hash) ? contexts : {}
235
+ end
236
+
237
+ def current_override
238
+ Thread.current.thread_variable_get(OVERRIDE_THREAD_KEY)
239
+ end
240
+
131
241
  private_class_method(
242
+ :validate_override_options,
243
+ :validate_context_override_options,
132
244
  :execute_tries,
133
- :build_intervals,
245
+ :retry_plan,
246
+ :interval_provider,
134
247
  :call_with_timeout,
135
248
  :call_on_retry,
136
249
  :can_retry?,
137
250
  :retriable_exception?,
138
251
  :hash_exception_match?,
252
+ :apply_override_options,
253
+ :available_contexts,
254
+ :context_options_for,
255
+ :config_contexts,
256
+ :override_contexts,
257
+ :current_override,
139
258
  )
140
259
  end