retriable 3.8.0 → 4.2.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.
@@ -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)
@@ -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 = "4.2.0"
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"
@@ -41,7 +40,9 @@ module Retriable
41
40
  end
42
41
  end
43
42
 
44
- def with_context(context_key, options = {}, &block)
43
+ def with_context(context_key, options = {}, &)
44
+ raise ArgumentError, "with_context requires a block" unless block_given?
45
+
45
46
  contexts = available_contexts
46
47
 
47
48
  if !contexts.key?(context_key)
@@ -49,27 +50,25 @@ module Retriable
49
50
  "#{context_key} not found in Retriable contexts (including overrides). Available contexts: #{contexts.keys}"
50
51
  end
51
52
 
52
- return unless block_given?
53
-
54
- retriable(context_options_for(context_key, options), &block)
53
+ retriable(context_options_for(context_key, options), &)
55
54
  end
56
55
 
57
- def retriable(opts = {}, &block)
56
+ def retriable(opts = {}, &)
58
57
  override_config = current_override
59
58
  local_config = if opts.empty? && !override_config
60
59
  config
61
60
  else
62
- Config.new(apply_override_options(config.to_h.merge(opts), override_config))
61
+ Config.new(apply_override_options(merge_layer(config.to_h, opts), override_config))
63
62
  end
64
63
 
65
64
  # Config is mutable through `configure`, so validate again immediately before use.
66
65
  local_config.validate!
67
66
 
68
67
  plan = retry_plan(local_config)
69
- timeout = local_config.timeout
70
68
  on = local_config.on
71
69
  retry_if = local_config.retry_if
72
70
  on_retry = local_config.on_retry
71
+ on_give_up = local_config.on_give_up
73
72
  sleep_disabled = local_config.sleep_disabled
74
73
  max_elapsed_time = local_config.max_elapsed_time
75
74
 
@@ -79,30 +78,39 @@ module Retriable
79
78
  elapsed_time = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time }
80
79
 
81
80
  execute_tries(
82
- max_tries: plan.max_tries, interval_for: plan.interval_for, timeout: timeout,
81
+ max_tries: plan.max_tries, interval_for: plan.interval_for,
83
82
  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
83
+ on_give_up: on_give_up, elapsed_time: elapsed_time, max_elapsed_time: max_elapsed_time,
84
+ sleep_disabled: sleep_disabled, &
86
85
  )
87
86
  end
88
87
 
89
88
  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
89
+ max_tries:, interval_for:, exception_list:,
90
+ on:, retry_if:, on_retry:, on_give_up:, elapsed_time:, max_elapsed_time:, sleep_disabled:
92
91
  )
93
92
  try = 0
94
93
  loop do
95
94
  try += 1
96
95
  begin
97
- return call_with_timeout(timeout, try, &block)
96
+ return yield(try)
98
97
  rescue *exception_list => e
99
98
  raise unless retriable_exception?(e, on, exception_list, retry_if)
100
99
 
100
+ # On the final attempt `interval_for` returns nil (no next retry), and
101
+ # `on_retry` intentionally fires before the give-up check below, so it
102
+ # receives `interval: nil`. See the on_retry/on_give_up README contract.
101
103
  interval = interval_for.call(try - 1)
102
104
  call_on_retry(on_retry, e, try, elapsed_time.call, interval)
103
105
 
104
106
  elapsed_interval = sleep_disabled == true ? 0 : interval
105
- raise unless can_retry?(try, max_tries, elapsed_time.call, elapsed_interval, max_elapsed_time)
107
+ # Snapshot elapsed_time once so the stop check and on_give_up see the same value.
108
+ current_elapsed_time = elapsed_time.call
109
+ stop_reason = retry_stop_reason(try, max_tries, current_elapsed_time, elapsed_interval, max_elapsed_time)
110
+ if stop_reason
111
+ call_on_give_up(on_give_up, e, try, current_elapsed_time, interval, stop_reason)
112
+ raise
113
+ end
106
114
 
107
115
  sleep interval if sleep_disabled != true
108
116
  end
@@ -132,23 +140,30 @@ module Retriable
132
140
  ).interval_provider
133
141
  end
134
142
 
135
- def call_with_timeout(timeout, try)
136
- return Timeout.timeout(timeout) { yield(try) } if timeout
137
-
138
- yield(try)
139
- end
140
-
141
143
  def call_on_retry(on_retry, exception, try, elapsed_time, interval)
142
144
  return unless on_retry
143
145
 
144
146
  on_retry.call(exception, try, elapsed_time, interval)
145
147
  end
146
148
 
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?
149
+ def call_on_give_up( # rubocop:disable Metrics/ParameterLists
150
+ on_give_up, exception, try, elapsed_time, interval, reason
151
+ )
152
+ return unless on_give_up
150
153
 
151
- (elapsed_time + interval) <= max_elapsed_time
154
+ on_give_up.call(exception, try, elapsed_time, interval, reason)
155
+ end
156
+
157
+ # `:tries_exhausted` is checked first, but the two conditions can't both hold
158
+ # on the same try in practice: `retry_plan` returns a nil interval whenever
159
+ # `try >= max_tries`, so `(elapsed_time + interval) > max_elapsed_time` is not
160
+ # evaluable on the exhausted-tries try. The early return guards against that
161
+ # nil and also pins precedence in case the plan ever changes.
162
+ def retry_stop_reason(try, max_tries, elapsed_time, interval, max_elapsed_time)
163
+ return :tries_exhausted if max_tries && try >= max_tries
164
+ return nil if max_elapsed_time.nil?
165
+
166
+ :max_elapsed_time if (elapsed_time + interval) > max_elapsed_time
152
167
  end
153
168
 
154
169
  # When `on` is a Hash, we need to verify the exception matches a pattern.
@@ -204,9 +219,17 @@ module Retriable
204
219
  def apply_override_options(options, overrides)
205
220
  return options unless overrides
206
221
 
207
- options = options.merge(overrides)
208
- options[:intervals] = nil if overrides.key?(:tries) && !overrides.key?(:intervals)
209
- options
222
+ merge_layer(options, overrides)
223
+ end
224
+
225
+ # Merge a higher-precedence option layer onto a base layer. A higher layer
226
+ # that sets `tries` without `intervals` clears the base layer's inherited
227
+ # `intervals`, so a caller's `tries:` is never silently ignored. When the
228
+ # higher layer supplies its own `intervals`, those win (same-call override).
229
+ def merge_layer(base, higher)
230
+ merged = base.merge(higher)
231
+ merged[:intervals] = nil if higher.key?(:tries) && !higher.key?(:intervals)
232
+ merged
210
233
  end
211
234
 
212
235
  def available_contexts
@@ -216,7 +239,7 @@ module Retriable
216
239
  def context_options_for(context_key, options)
217
240
  context_options = config_contexts.fetch(context_key, {})
218
241
  context_options = {} unless context_options.is_a?(Hash)
219
- context_options = context_options.merge(options)
242
+ context_options = merge_layer(context_options, options)
220
243
 
221
244
  override_context_options = override_contexts[context_key]
222
245
  return context_options unless override_context_options.is_a?(Hash)
@@ -244,12 +267,13 @@ module Retriable
244
267
  :execute_tries,
245
268
  :retry_plan,
246
269
  :interval_provider,
247
- :call_with_timeout,
248
270
  :call_on_retry,
249
- :can_retry?,
271
+ :call_on_give_up,
272
+ :retry_stop_reason,
250
273
  :retriable_exception?,
251
274
  :hash_exception_match?,
252
275
  :apply_override_options,
276
+ :merge_layer,
253
277
  :available_contexts,
254
278
  :context_options_for,
255
279
  :config_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: () { (Config) -> void } -> void
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
data/spec/config_spec.rb CHANGED
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "stringio"
4
-
5
3
  describe Retriable::Config do
6
4
  let(:default_config) { described_class.new }
7
5
 
@@ -34,10 +32,6 @@ describe Retriable::Config do
34
32
  expect(default_config.intervals).to be_nil
35
33
  end
36
34
 
37
- it "timeout defaults to nil" do
38
- expect(default_config.timeout).to be_nil
39
- end
40
-
41
35
  it "on defaults to [StandardError]" do
42
36
  expect(default_config.on).to eq([StandardError])
43
37
  end
@@ -50,6 +44,10 @@ describe Retriable::Config do
50
44
  expect(default_config.on_retry).to be_nil
51
45
  end
52
46
 
47
+ it "on_give_up handler defaults to nil" do
48
+ expect(default_config.on_give_up).to be_nil
49
+ end
50
+
53
51
  it "contexts defaults to {}" do
54
52
  expect(default_config.contexts).to eq({})
55
53
  end
@@ -59,99 +57,12 @@ describe Retriable::Config do
59
57
  expect { described_class.new(does_not_exist: 123) }.to raise_error(ArgumentError, /not a valid option/)
60
58
  end
61
59
 
62
- it "raises errors on invalid timing configuration" do
63
- expect { described_class.new(rand_factor: 1.1) }.to raise_error(ArgumentError, /rand_factor/)
64
- expect do
65
- expect { described_class.new(timeout: -1) }.to raise_error(ArgumentError, /timeout/)
66
- end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
60
+ it "rejects timeout as an unknown option" do
61
+ expect { described_class.new(timeout: 5) }.to raise_error(ArgumentError, /not a valid option/)
67
62
  end
68
63
 
69
- context "timeout deprecation" do
70
- it "warns when timeout is configured" do
71
- expect do
72
- described_class.new(timeout: 5)
73
- end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
74
- end
75
-
76
- it "warns when timeout is set before validation" do
77
- config = described_class.new
78
- config.timeout = 5
79
-
80
- expect do
81
- config.validate!
82
- end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
83
- end
84
-
85
- it "does not warn when timeout is nil" do
86
- expect do
87
- described_class.new(timeout: nil)
88
- end.not_to output.to_stderr
89
- end
90
-
91
- it "does not warn when timeout is omitted" do
92
- expect do
93
- described_class.new
94
- end.not_to output.to_stderr
95
- end
96
-
97
- it "warns at most once per process" do
98
- original_stderr = $stderr
99
- stderr = StringIO.new
100
- begin
101
- $stderr = stderr
102
-
103
- described_class.new(timeout: 5)
104
- described_class.new(timeout: 5)
105
-
106
- config = described_class.new
107
- config.timeout = 5
108
- config.validate!
109
- ensure
110
- $stderr = original_stderr
111
- end
112
-
113
- expect(stderr.string.scan("timeout:` option is deprecated").size).to eq(1)
114
- end
115
-
116
- it "emits the warning under the :deprecated category when supported", if: WARN_CATEGORY_SUPPORTED do
117
- captured = []
118
- allow(Warning).to receive(:warn) do |message, category: nil|
119
- captured << [message, category]
120
- end
121
-
122
- described_class.new(timeout: 5)
123
-
124
- expect(captured.size).to eq(1)
125
- message, category = captured.first
126
- expect(message).to match(/timeout.*deprecated.*Retriable 4\.0/i)
127
- expect(category).to eq(:deprecated)
128
- end
129
-
130
- it "is silenced by Warning[:deprecated] = false", if: WARN_CATEGORY_SUPPORTED do
131
- original = Warning[:deprecated]
132
- begin
133
- Warning[:deprecated] = false
134
- expect do
135
- described_class.new(timeout: 5)
136
- end.not_to output.to_stderr
137
- ensure
138
- Warning[:deprecated] = original
139
- end
140
- end
141
-
142
- it "remains armed when silenced via Warning[:deprecated]", if: WARN_CATEGORY_SUPPORTED do
143
- original = Warning[:deprecated]
144
- begin
145
- Warning[:deprecated] = false
146
- described_class.new(timeout: 5)
147
- ensure
148
- Warning[:deprecated] = original
149
- end
150
-
151
- expect do
152
- described_class.new(timeout: 5)
153
- end.to output(/timeout.*deprecated.*Retriable 4\.0/i).to_stderr
154
- end
64
+ it "raises errors on invalid timing configuration" do
65
+ expect { described_class.new(rand_factor: 1.1) }.to raise_error(ArgumentError, /rand_factor/)
155
66
  end
156
67
 
157
68
  it "raises errors when intervals is not an array" do
@@ -191,6 +102,15 @@ describe Retriable::Config do
191
102
  expect { described_class.new(on: [StandardError, RuntimeError]) }.not_to raise_error
192
103
  end
193
104
 
105
+ it "accepts a Set of Exception subclasses" do
106
+ expect { described_class.new(on: Set[StandardError, RuntimeError]) }.not_to raise_error
107
+ end
108
+
109
+ it "rejects a Set containing a non-Exception class" do
110
+ expect { described_class.new(on: Set[StandardError, Kernel]) }
111
+ .to raise_error(ArgumentError, /on must be an Exception class/)
112
+ end
113
+
194
114
  it "accepts a hash with nil pattern values" do
195
115
  expect { described_class.new(on: { StandardError => nil }) }.not_to raise_error
196
116
  end
@@ -243,4 +163,50 @@ describe Retriable::Config do
243
163
  .to raise_error(ArgumentError, /on must be an Exception class/)
244
164
  end
245
165
  end
166
+
167
+ context "callable option validation" do
168
+ %i[retry_if on_retry on_give_up].each do |opt|
169
+ it "accepts a callable for #{opt}" do
170
+ expect { described_class.new(opt => ->(*) {}) }.not_to raise_error
171
+ end
172
+
173
+ it "accepts nil and false for #{opt}" do
174
+ expect { described_class.new(opt => nil) }.not_to raise_error
175
+ expect { described_class.new(opt => false) }.not_to raise_error
176
+ end
177
+
178
+ it "rejects a non-callable truthy value for #{opt}" do
179
+ expect { described_class.new(opt => 5) }.to raise_error(ArgumentError, /#{opt}.*#call/)
180
+ end
181
+ end
182
+ end
183
+
184
+ context "context structure validation" do
185
+ it "rejects a context whose options contain a nested :contexts key" do
186
+ expect { described_class.new(contexts: { api: { contexts: {} } }) }
187
+ .to raise_error(ArgumentError, /contexts is not a valid option/)
188
+ end
189
+
190
+ it "rejects a context with an unknown option key" do
191
+ expect { described_class.new(contexts: { api: { does_not_exist: 1 } }) }
192
+ .to raise_error(ArgumentError, /does_not_exist is not a valid option/)
193
+ end
194
+
195
+ it "validates context structure even when intervals is provided" do
196
+ expect { described_class.new(intervals: [0.1], contexts: { api: { contexts: {} } }) }
197
+ .to raise_error(ArgumentError, /contexts is not a valid option/)
198
+ end
199
+
200
+ it "accepts a non-Hash context value (treated as empty options)" do
201
+ expect { described_class.new(contexts: { broken: nil }) }.not_to raise_error
202
+ end
203
+
204
+ it "accepts nil contexts" do
205
+ expect { described_class.new(contexts: nil) }.not_to raise_error
206
+ end
207
+
208
+ it "accepts a valid context" do
209
+ expect { described_class.new(contexts: { api: { tries: 3, base_interval: 1.0 } }) }.not_to raise_error
210
+ end
211
+ end
246
212
  end