retriable 3.4.1 → 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.
@@ -1,37 +1,43 @@
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
10
13
  intervals
11
- timeout
12
14
  on
13
15
  retry_if
14
16
  on_retry
17
+ on_give_up
15
18
  contexts
16
19
  ]).freeze
17
20
 
21
+ CONTEXT_ATTRIBUTES = (ATTRIBUTES - %i[contexts]).freeze
22
+ private_constant :CONTEXT_ATTRIBUTES
23
+
18
24
  attr_accessor(*ATTRIBUTES)
19
25
 
20
26
  def initialize(opts = {})
21
- backoff = ExponentialBackoff.new
27
+ defaults = ExponentialBackoff::DEFAULTS
22
28
 
23
- @tries = backoff.tries
24
- @base_interval = backoff.base_interval
25
- @max_interval = backoff.max_interval
26
- @rand_factor = backoff.rand_factor
27
- @multiplier = backoff.multiplier
29
+ @tries = defaults[:tries]
30
+ @base_interval = defaults[:base_interval]
31
+ @max_interval = defaults[:max_interval]
32
+ @rand_factor = defaults[:rand_factor]
33
+ @multiplier = defaults[:multiplier]
28
34
  @sleep_disabled = false
29
35
  @max_elapsed_time = 900 # 15 min
30
36
  @intervals = nil
31
- @timeout = nil
32
37
  @on = [StandardError]
33
38
  @retry_if = nil
34
39
  @on_retry = nil
40
+ @on_give_up = nil
35
41
  @contexts = {}
36
42
 
37
43
  opts.each do |k, v|
@@ -39,12 +45,77 @@ module Retriable
39
45
 
40
46
  instance_variable_set(:"@#{k}", v)
41
47
  end
48
+
49
+ validate!
42
50
  end
43
51
 
44
52
  def to_h
45
- ATTRIBUTES.each_with_object({}) do |key, hash|
46
- hash[key] = public_send(key)
53
+ ATTRIBUTES.to_h { |key| [key, public_send(key)] }
54
+ end
55
+
56
+ def validate!
57
+ validate_contexts
58
+ validate_callable(:retry_if, retry_if)
59
+ validate_callable(:on_retry, on_retry)
60
+ validate_callable(:on_give_up, on_give_up)
61
+ validate_on(on)
62
+ validate_intervals
63
+ if unbounded_tries?(tries)
64
+ validate_unbounded_tries
65
+ else
66
+ validate_optional_non_negative_number(:max_elapsed_time, max_elapsed_time)
67
+ return if intervals
68
+
69
+ validate_positive_integer(:tries, tries)
47
70
  end
71
+
72
+ validate_backoff_options
73
+ end
74
+
75
+ private
76
+
77
+ def validate_contexts
78
+ return unless contexts.is_a?(Hash)
79
+ return if contexts.empty?
80
+
81
+ contexts.each_value do |options|
82
+ next unless options.is_a?(Hash)
83
+
84
+ options.each_key do |k|
85
+ next if CONTEXT_ATTRIBUTES.include?(k)
86
+
87
+ raise ArgumentError, "#{k} is not a valid option"
88
+ end
89
+ end
90
+ end
91
+
92
+ def validate_backoff_options
93
+ validate_non_negative_number(:base_interval, base_interval)
94
+ validate_non_negative_number(:multiplier, multiplier)
95
+ validate_non_negative_number(:max_interval, max_interval)
96
+ validate_rand_factor
97
+ end
98
+
99
+ def validate_unbounded_tries
100
+ if intervals
101
+ raise ArgumentError,
102
+ "intervals cannot be used with tries: Float::INFINITY"
103
+ end
104
+
105
+ unless finite_number?(max_elapsed_time)
106
+ raise ArgumentError,
107
+ "max_elapsed_time must be a finite number when tries is Float::INFINITY"
108
+ end
109
+
110
+ validate_non_negative_number(:max_elapsed_time, max_elapsed_time)
111
+ end
112
+
113
+ def validate_intervals
114
+ return if intervals.nil?
115
+ raise ArgumentError, "intervals must be an Array" unless intervals.is_a?(Array)
116
+ return if intervals.all? { |interval| finite_number?(interval) && interval >= 0 }
117
+
118
+ raise ArgumentError, "intervals must contain only non-negative numbers"
48
119
  end
49
120
  end
50
121
  end
@@ -3,11 +3,13 @@
3
3
  require_relative "../../retriable"
4
4
 
5
5
  module Kernel
6
- def retriable(opts = {}, &block)
7
- Retriable.retriable(opts, &block)
6
+ def retriable(opts = {}, &)
7
+ Retriable.retriable(opts, &)
8
8
  end
9
9
 
10
- def retriable_with_context(context_key, opts = {}, &block)
11
- Retriable.with_context(context_key, opts, &block)
10
+ def retriable_with_context(context_key, opts = {}, &)
11
+ Retriable.with_context(context_key, opts, &)
12
12
  end
13
+
14
+ private :retriable, :retriable_with_context
13
15
  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
@@ -10,34 +14,64 @@ module Retriable
10
14
  rand_factor
11
15
  ].freeze
12
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
+
13
25
  attr_accessor(*ATTRIBUTES)
14
26
 
15
27
  def initialize(opts = {})
16
- @tries = 3
17
- @base_interval = 0.5
18
- @max_interval = 60
19
- @rand_factor = 0.5
20
- @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]
21
33
 
22
34
  opts.each do |k, v|
23
35
  raise ArgumentError, "#{k} is not a valid option" if !ATTRIBUTES.include?(k)
24
36
 
25
37
  instance_variable_set(:"@#{k}", v)
26
38
  end
39
+
40
+ validate!
27
41
  end
28
42
 
29
43
  def intervals
30
- intervals = Array.new(tries) do |iteration|
31
- [base_interval * (multiplier**iteration), max_interval].min
32
- end
44
+ provider = interval_provider
45
+ Array.new(tries) { |iteration| provider.call(iteration) }
46
+ end
47
+
48
+ def interval_provider
49
+ raw_interval = base_interval
33
50
 
34
- return intervals if rand_factor.zero?
51
+ lambda do |_iteration|
52
+ interval = [raw_interval, max_interval].min
53
+ raw_interval = next_raw_interval(raw_interval)
35
54
 
36
- intervals.map { |i| randomize(i) }
55
+ rand_factor.zero? ? interval : randomize(interval)
56
+ end
37
57
  end
38
58
 
39
59
  private
40
60
 
61
+ def validate!
62
+ validate_non_negative_integer(:tries, tries)
63
+ validate_non_negative_number(:base_interval, base_interval)
64
+ validate_non_negative_number(:multiplier, multiplier)
65
+ validate_non_negative_number(:max_interval, max_interval)
66
+ validate_rand_factor
67
+ end
68
+
69
+ def next_raw_interval(raw_interval)
70
+ return max_interval if multiplier >= 1 && raw_interval >= max_interval
71
+
72
+ raw_interval * multiplier
73
+ end
74
+
41
75
  def randomize(interval)
42
76
  delta = rand_factor * interval.to_f
43
77
  min = interval - delta
@@ -0,0 +1,95 @@
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_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
+
38
+ def validate_rand_factor
39
+ return if finite_number?(rand_factor) && rand_factor >= 0 && rand_factor <= 1
40
+
41
+ raise ArgumentError, "rand_factor must be between 0 and 1"
42
+ end
43
+
44
+ def finite_number?(value)
45
+ value.is_a?(Numeric) && value.to_f.finite?
46
+ end
47
+
48
+ def unbounded_tries?(value)
49
+ value.is_a?(Numeric) && value.respond_to?(:infinite?) && value.infinite? == 1
50
+ end
51
+
52
+ module_function :unbounded_tries?
53
+
54
+ # Validates an `on:` value. Acceptable shapes:
55
+ # - a Class that descends from Exception
56
+ # - an Array or Set whose elements are Classes that descend from Exception
57
+ # - a Hash whose keys are such Classes and whose values are nil,
58
+ # a Regexp, or an Array of Regexps
59
+ #
60
+ # Without this validation, callers can pass values like `Object` or
61
+ # `Kernel` and silently retry process-critical exceptions such as
62
+ # SystemExit and Interrupt, because every Exception's ancestor chain
63
+ # includes both. Hash values that are not Regexps (e.g. plain Strings)
64
+ # also silently fail to match in #hash_exception_match?, so we require
65
+ # Regexp values explicitly.
66
+ def validate_on(value)
67
+ case value
68
+ in Hash
69
+ value.each do |klass, pattern|
70
+ validate_on_class(klass)
71
+ validate_on_hash_value(klass, pattern)
72
+ end
73
+ in Array | Set
74
+ value.each { |klass| validate_on_class(klass) }
75
+ else
76
+ validate_on_class(value)
77
+ end
78
+ end
79
+
80
+ def validate_on_class(klass)
81
+ return if klass.is_a?(Class) && klass <= Exception
82
+
83
+ raise ArgumentError, "on must be an Exception class or a collection of Exception classes, got #{klass.inspect}"
84
+ end
85
+
86
+ def validate_on_hash_value(klass, pattern)
87
+ return if pattern.nil?
88
+ return if pattern.is_a?(Regexp)
89
+ return if pattern.is_a?(Array) && pattern.all?(Regexp)
90
+
91
+ raise ArgumentError,
92
+ "on[#{klass}] must be nil, a Regexp, or an Array of Regexps, got #{pattern.inspect}"
93
+ end
94
+ end
95
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Retriable
4
- VERSION = "3.4.1"
4
+ VERSION = "4.2.0"
5
5
  end
data/lib/retriable.rb CHANGED
@@ -1,11 +1,20 @@
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"
7
6
 
8
7
  module Retriable
8
+ # Thread-local storage key for the active #with_override block.
9
+ # We deliberately use Thread#thread_variable_set/get (true thread-local)
10
+ # rather than Thread.current[] (fiber-local) so that fibers within a thread
11
+ # share the same override. Changing this to Thread.current[] would silently
12
+ # break callers that use fiber-based concurrency.
13
+ OVERRIDE_THREAD_KEY = :retriable_override
14
+
15
+ RetryPlan = Struct.new(:max_tries, :interval_for)
16
+ private_constant :RetryPlan
17
+
9
18
  module_function
10
19
 
11
20
  def configure
@@ -16,26 +25,50 @@ module Retriable
16
25
  @config ||= Config.new
17
26
  end
18
27
 
19
- def with_context(context_key, options = {}, &block)
20
- if !config.contexts.key?(context_key)
21
- raise ArgumentError,
22
- "#{context_key} not found in Retriable.config.contexts. Available contexts: #{config.contexts.keys}"
28
+ def with_override(opts = {})
29
+ raise ArgumentError, "empty override options are not allowed" if opts.empty?
30
+ raise ArgumentError, "with_override requires a block" unless block_given?
31
+
32
+ validate_override_options(opts)
33
+
34
+ previous = Thread.current.thread_variable_get(OVERRIDE_THREAD_KEY)
35
+ Thread.current.thread_variable_set(OVERRIDE_THREAD_KEY, opts)
36
+ begin
37
+ yield
38
+ ensure
39
+ Thread.current.thread_variable_set(OVERRIDE_THREAD_KEY, previous)
23
40
  end
41
+ end
42
+
43
+ def with_context(context_key, options = {}, &)
44
+ raise ArgumentError, "with_context requires a block" unless block_given?
24
45
 
25
- return unless block_given?
46
+ contexts = available_contexts
26
47
 
27
- retriable(config.contexts[context_key].merge(options), &block)
48
+ if !contexts.key?(context_key)
49
+ raise ArgumentError,
50
+ "#{context_key} not found in Retriable contexts (including overrides). Available contexts: #{contexts.keys}"
51
+ end
52
+
53
+ retriable(context_options_for(context_key, options), &)
28
54
  end
29
55
 
30
- def retriable(opts = {}, &block)
31
- local_config = opts.empty? ? config : Config.new(config.to_h.merge(opts))
56
+ def retriable(opts = {}, &)
57
+ override_config = current_override
58
+ local_config = if opts.empty? && !override_config
59
+ config
60
+ else
61
+ Config.new(apply_override_options(merge_layer(config.to_h, opts), override_config))
62
+ end
63
+
64
+ # Config is mutable through `configure`, so validate again immediately before use.
65
+ local_config.validate!
32
66
 
33
- tries = local_config.tries
34
- intervals = build_intervals(local_config, tries)
35
- timeout = local_config.timeout
67
+ plan = retry_plan(local_config)
36
68
  on = local_config.on
37
69
  retry_if = local_config.retry_if
38
70
  on_retry = local_config.on_retry
71
+ on_give_up = local_config.on_give_up
39
72
  sleep_disabled = local_config.sleep_disabled
40
73
  max_elapsed_time = local_config.max_elapsed_time
41
74
 
@@ -44,54 +77,67 @@ module Retriable
44
77
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
45
78
  elapsed_time = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time }
46
79
 
47
- tries = intervals.size + 1
48
-
49
80
  execute_tries(
50
- tries: tries, intervals: intervals, timeout: timeout,
81
+ max_tries: plan.max_tries, interval_for: plan.interval_for,
51
82
  exception_list: exception_list, on: on, retry_if: retry_if, on_retry: on_retry,
52
- elapsed_time: elapsed_time, max_elapsed_time: max_elapsed_time,
53
- 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, &
54
85
  )
55
86
  end
56
87
 
57
88
  def execute_tries( # rubocop:disable Metrics/ParameterLists
58
- tries:, intervals:, timeout:, exception_list:,
59
- 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:
60
91
  )
61
- tries.times do |index|
62
- try = index + 1
63
-
92
+ try = 0
93
+ loop do
94
+ try += 1
64
95
  begin
65
- return call_with_timeout(timeout, try, &block)
96
+ return yield(try)
66
97
  rescue *exception_list => e
67
98
  raise unless retriable_exception?(e, on, exception_list, retry_if)
68
99
 
69
- interval = intervals[index]
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.
103
+ interval = interval_for.call(try - 1)
70
104
  call_on_retry(on_retry, e, try, elapsed_time.call, interval)
71
105
 
72
- raise unless can_retry?(try, tries, elapsed_time.call, interval, max_elapsed_time)
106
+ elapsed_interval = sleep_disabled == true ? 0 : interval
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
73
114
 
74
115
  sleep interval if sleep_disabled != true
75
116
  end
76
117
  end
77
118
  end
78
119
 
79
- def build_intervals(local_config, tries)
80
- return local_config.intervals if local_config.intervals
120
+ def retry_plan(local_config)
121
+ return RetryPlan.new(nil, interval_provider(local_config)) if Validation.unbounded_tries?(local_config.tries)
122
+
123
+ if local_config.intervals
124
+ intervals = local_config.intervals
125
+ return RetryPlan.new(intervals.size + 1, ->(index) { intervals[index] })
126
+ end
127
+
128
+ max_tries = local_config.tries
129
+ provider = interval_provider(local_config)
130
+
131
+ RetryPlan.new(max_tries, ->(index) { index < max_tries - 1 ? provider.call(index) : nil })
132
+ end
81
133
 
134
+ def interval_provider(local_config)
82
135
  ExponentialBackoff.new(
83
- tries: tries - 1,
84
136
  base_interval: local_config.base_interval,
85
137
  multiplier: local_config.multiplier,
86
138
  max_interval: local_config.max_interval,
87
139
  rand_factor: local_config.rand_factor,
88
- ).intervals
89
- end
90
-
91
- def call_with_timeout(timeout, try)
92
- return Timeout.timeout(timeout) { yield(try) } if timeout
93
-
94
- yield(try)
140
+ ).interval_provider
95
141
  end
96
142
 
97
143
  def call_on_retry(on_retry, exception, try, elapsed_time, interval)
@@ -100,11 +146,24 @@ module Retriable
100
146
  on_retry.call(exception, try, elapsed_time, interval)
101
147
  end
102
148
 
103
- def can_retry?(try, tries, elapsed_time, interval, max_elapsed_time)
104
- return false unless try < tries
105
- 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
153
+
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?
106
165
 
107
- (elapsed_time + interval) <= max_elapsed_time
166
+ :max_elapsed_time if (elapsed_time + interval) > max_elapsed_time
108
167
  end
109
168
 
110
169
  # When `on` is a Hash, we need to verify the exception matches a pattern.
@@ -128,13 +187,97 @@ module Retriable
128
187
  end
129
188
  end
130
189
 
190
+ def validate_override_options(opts)
191
+ opts.each_key do |k|
192
+ raise ArgumentError, "#{k} is not a valid option" unless Config::ATTRIBUTES.include?(k)
193
+ end
194
+
195
+ return unless opts.key?(:contexts)
196
+
197
+ contexts = opts[:contexts]
198
+ return if contexts.nil?
199
+
200
+ raise ArgumentError, "contexts must be a Hash or nil, got #{contexts.inspect}" unless contexts.is_a?(Hash)
201
+
202
+ contexts.each do |context_key, context_options|
203
+ validate_context_override_options(context_key, context_options)
204
+ end
205
+ end
206
+
207
+ def validate_context_override_options(context_key, context_options)
208
+ unless context_options.is_a?(Hash)
209
+ raise ArgumentError,
210
+ "contexts[#{context_key.inspect}] must be a Hash, got #{context_options.inspect}"
211
+ end
212
+
213
+ context_attributes = Config::ATTRIBUTES - [:contexts]
214
+ context_options.each_key do |k|
215
+ raise ArgumentError, "#{k} is not a valid option" unless context_attributes.include?(k)
216
+ end
217
+ end
218
+
219
+ def apply_override_options(options, overrides)
220
+ return options unless overrides
221
+
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
233
+ end
234
+
235
+ def available_contexts
236
+ config_contexts.merge(override_contexts)
237
+ end
238
+
239
+ def context_options_for(context_key, options)
240
+ context_options = config_contexts.fetch(context_key, {})
241
+ context_options = {} unless context_options.is_a?(Hash)
242
+ context_options = merge_layer(context_options, options)
243
+
244
+ override_context_options = override_contexts[context_key]
245
+ return context_options unless override_context_options.is_a?(Hash)
246
+
247
+ apply_override_options(context_options, override_context_options)
248
+ end
249
+
250
+ def config_contexts
251
+ config.contexts.is_a?(Hash) ? config.contexts : {}
252
+ end
253
+
254
+ def override_contexts
255
+ override_config = current_override
256
+ contexts = override_config && override_config[:contexts]
257
+ contexts.is_a?(Hash) ? contexts : {}
258
+ end
259
+
260
+ def current_override
261
+ Thread.current.thread_variable_get(OVERRIDE_THREAD_KEY)
262
+ end
263
+
131
264
  private_class_method(
265
+ :validate_override_options,
266
+ :validate_context_override_options,
132
267
  :execute_tries,
133
- :build_intervals,
134
- :call_with_timeout,
268
+ :retry_plan,
269
+ :interval_provider,
135
270
  :call_on_retry,
136
- :can_retry?,
271
+ :call_on_give_up,
272
+ :retry_stop_reason,
137
273
  :retriable_exception?,
138
274
  :hash_exception_match?,
275
+ :apply_override_options,
276
+ :merge_layer,
277
+ :available_contexts,
278
+ :context_options_for,
279
+ :config_contexts,
280
+ :override_contexts,
281
+ :current_override,
139
282
  )
140
283
  end
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