retriable 3.4.1 → 4.1.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.
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)
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)
40
+ end
41
+ end
42
+
43
+ def with_context(context_key, options = {}, &)
44
+ contexts = available_contexts
45
+
46
+ if !contexts.key?(context_key)
21
47
  raise ArgumentError,
22
- "#{context_key} not found in Retriable.config.contexts. Available contexts: #{config.contexts.keys}"
48
+ "#{context_key} not found in Retriable contexts (including overrides). Available contexts: #{contexts.keys}"
23
49
  end
24
50
 
25
51
  return unless block_given?
26
52
 
27
- retriable(config.contexts[context_key].merge(options), &block)
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,64 @@ 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
+ interval = interval_for.call(try - 1)
70
101
  call_on_retry(on_retry, e, try, elapsed_time.call, interval)
71
102
 
72
- raise unless can_retry?(try, tries, elapsed_time.call, interval, max_elapsed_time)
103
+ elapsed_interval = sleep_disabled == true ? 0 : interval
104
+ # Snapshot elapsed_time once so the stop check and on_give_up see the same value.
105
+ current_elapsed_time = elapsed_time.call
106
+ stop_reason = retry_stop_reason(try, max_tries, current_elapsed_time, elapsed_interval, max_elapsed_time)
107
+ if stop_reason
108
+ call_on_give_up(on_give_up, e, try, current_elapsed_time, interval, stop_reason)
109
+ raise
110
+ end
73
111
 
74
112
  sleep interval if sleep_disabled != true
75
113
  end
76
114
  end
77
115
  end
78
116
 
79
- def build_intervals(local_config, tries)
80
- return local_config.intervals if local_config.intervals
117
+ def retry_plan(local_config)
118
+ return RetryPlan.new(nil, interval_provider(local_config)) if Validation.unbounded_tries?(local_config.tries)
119
+
120
+ if local_config.intervals
121
+ intervals = local_config.intervals
122
+ return RetryPlan.new(intervals.size + 1, ->(index) { intervals[index] })
123
+ end
124
+
125
+ max_tries = local_config.tries
126
+ provider = interval_provider(local_config)
81
127
 
128
+ RetryPlan.new(max_tries, ->(index) { index < max_tries - 1 ? provider.call(index) : nil })
129
+ end
130
+
131
+ def interval_provider(local_config)
82
132
  ExponentialBackoff.new(
83
- tries: tries - 1,
84
133
  base_interval: local_config.base_interval,
85
134
  multiplier: local_config.multiplier,
86
135
  max_interval: local_config.max_interval,
87
136
  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)
137
+ ).interval_provider
95
138
  end
96
139
 
97
140
  def call_on_retry(on_retry, exception, try, elapsed_time, interval)
@@ -100,11 +143,24 @@ module Retriable
100
143
  on_retry.call(exception, try, elapsed_time, interval)
101
144
  end
102
145
 
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?
146
+ def call_on_give_up( # rubocop:disable Metrics/ParameterLists
147
+ on_give_up, exception, try, elapsed_time, interval, reason
148
+ )
149
+ return unless on_give_up
106
150
 
107
- (elapsed_time + interval) <= max_elapsed_time
151
+ on_give_up.call(exception, try, elapsed_time, interval, reason)
152
+ end
153
+
154
+ # `:tries_exhausted` is checked first, but the two conditions can't both hold
155
+ # on the same try in practice: `retry_plan` returns a nil interval whenever
156
+ # `try >= max_tries`, so `(elapsed_time + interval) > max_elapsed_time` is not
157
+ # evaluable on the exhausted-tries try. The early return guards against that
158
+ # nil and also pins precedence in case the plan ever changes.
159
+ def retry_stop_reason(try, max_tries, elapsed_time, interval, max_elapsed_time)
160
+ return :tries_exhausted if max_tries && try >= max_tries
161
+ return nil if max_elapsed_time.nil?
162
+
163
+ :max_elapsed_time if (elapsed_time + interval) > max_elapsed_time
108
164
  end
109
165
 
110
166
  # When `on` is a Hash, we need to verify the exception matches a pattern.
@@ -128,13 +184,97 @@ module Retriable
128
184
  end
129
185
  end
130
186
 
187
+ def validate_override_options(opts)
188
+ opts.each_key do |k|
189
+ raise ArgumentError, "#{k} is not a valid option" unless Config::ATTRIBUTES.include?(k)
190
+ end
191
+
192
+ return unless opts.key?(:contexts)
193
+
194
+ contexts = opts[:contexts]
195
+ return if contexts.nil?
196
+
197
+ raise ArgumentError, "contexts must be a Hash or nil, got #{contexts.inspect}" unless contexts.is_a?(Hash)
198
+
199
+ contexts.each do |context_key, context_options|
200
+ validate_context_override_options(context_key, context_options)
201
+ end
202
+ end
203
+
204
+ def validate_context_override_options(context_key, context_options)
205
+ unless context_options.is_a?(Hash)
206
+ raise ArgumentError,
207
+ "contexts[#{context_key.inspect}] must be a Hash, got #{context_options.inspect}"
208
+ end
209
+
210
+ context_attributes = Config::ATTRIBUTES - [:contexts]
211
+ context_options.each_key do |k|
212
+ raise ArgumentError, "#{k} is not a valid option" unless context_attributes.include?(k)
213
+ end
214
+ end
215
+
216
+ def apply_override_options(options, overrides)
217
+ return options unless overrides
218
+
219
+ merge_layer(options, overrides)
220
+ end
221
+
222
+ # Merge a higher-precedence option layer onto a base layer. A higher layer
223
+ # that sets `tries` without `intervals` clears the base layer's inherited
224
+ # `intervals`, so a caller's `tries:` is never silently ignored. When the
225
+ # higher layer supplies its own `intervals`, those win (same-call override).
226
+ def merge_layer(base, higher)
227
+ merged = base.merge(higher)
228
+ merged[:intervals] = nil if higher.key?(:tries) && !higher.key?(:intervals)
229
+ merged
230
+ end
231
+
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, {})
238
+ context_options = {} unless context_options.is_a?(Hash)
239
+ context_options = merge_layer(context_options, options)
240
+
241
+ override_context_options = override_contexts[context_key]
242
+ return context_options unless override_context_options.is_a?(Hash)
243
+
244
+ apply_override_options(context_options, override_context_options)
245
+ end
246
+
247
+ def config_contexts
248
+ config.contexts.is_a?(Hash) ? config.contexts : {}
249
+ end
250
+
251
+ def override_contexts
252
+ override_config = current_override
253
+ contexts = override_config && override_config[:contexts]
254
+ contexts.is_a?(Hash) ? contexts : {}
255
+ end
256
+
257
+ def current_override
258
+ Thread.current.thread_variable_get(OVERRIDE_THREAD_KEY)
259
+ end
260
+
131
261
  private_class_method(
262
+ :validate_override_options,
263
+ :validate_context_override_options,
132
264
  :execute_tries,
133
- :build_intervals,
134
- :call_with_timeout,
265
+ :retry_plan,
266
+ :interval_provider,
135
267
  :call_on_retry,
136
- :can_retry?,
268
+ :call_on_give_up,
269
+ :retry_stop_reason,
137
270
  :retriable_exception?,
138
271
  :hash_exception_match?,
272
+ :apply_override_options,
273
+ :merge_layer,
274
+ :available_contexts,
275
+ :context_options_for,
276
+ :config_contexts,
277
+ :override_contexts,
278
+ :current_override,
139
279
  )
140
280
  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
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
@@ -32,10 +32,6 @@ describe Retriable::Config do
32
32
  expect(default_config.intervals).to be_nil
33
33
  end
34
34
 
35
- it "timeout defaults to nil" do
36
- expect(default_config.timeout).to be_nil
37
- end
38
-
39
35
  it "on defaults to [StandardError]" do
40
36
  expect(default_config.on).to eq([StandardError])
41
37
  end
@@ -48,6 +44,10 @@ describe Retriable::Config do
48
44
  expect(default_config.on_retry).to be_nil
49
45
  end
50
46
 
47
+ it "on_give_up handler defaults to nil" do
48
+ expect(default_config.on_give_up).to be_nil
49
+ end
50
+
51
51
  it "contexts defaults to {}" do
52
52
  expect(default_config.contexts).to eq({})
53
53
  end
@@ -56,4 +56,128 @@ describe Retriable::Config do
56
56
  it "raises errors on invalid configuration" do
57
57
  expect { described_class.new(does_not_exist: 123) }.to raise_error(ArgumentError, /not a valid option/)
58
58
  end
59
+
60
+ it "rejects timeout as an unknown option" do
61
+ expect { described_class.new(timeout: 5) }.to raise_error(ArgumentError, /not a valid option/)
62
+ end
63
+
64
+ it "raises errors on invalid timing configuration" do
65
+ expect { described_class.new(rand_factor: 1.1) }.to raise_error(ArgumentError, /rand_factor/)
66
+ end
67
+
68
+ it "raises errors when intervals is not an array" do
69
+ expect { described_class.new(intervals: "1") }.to raise_error(ArgumentError, /intervals must be an Array/)
70
+ end
71
+
72
+ it "requires a finite max_elapsed_time when tries is Float::INFINITY" do
73
+ expect { described_class.new(tries: Float::INFINITY, max_elapsed_time: nil) }
74
+ .to raise_error(ArgumentError, /max_elapsed_time must be a finite number/)
75
+ end
76
+
77
+ it "rejects intervals combined with tries: Float::INFINITY" do
78
+ expect do
79
+ described_class.new(
80
+ tries: Float::INFINITY,
81
+ max_elapsed_time: 60,
82
+ intervals: [0.1, 0.2],
83
+ )
84
+ end.to raise_error(ArgumentError, /intervals cannot be used with tries: Float::INFINITY/)
85
+ end
86
+
87
+ it "accepts tries: Float::INFINITY with a finite max_elapsed_time" do
88
+ expect { described_class.new(tries: Float::INFINITY, max_elapsed_time: 60) }
89
+ .not_to raise_error
90
+ end
91
+
92
+ context "on: option validation" do
93
+ it "accepts a single Exception subclass" do
94
+ expect { described_class.new(on: StandardError) }.not_to raise_error
95
+ end
96
+
97
+ it "accepts Exception itself" do
98
+ expect { described_class.new(on: Exception) }.not_to raise_error
99
+ end
100
+
101
+ it "accepts an array of Exception subclasses" do
102
+ expect { described_class.new(on: [StandardError, RuntimeError]) }.not_to raise_error
103
+ end
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
+
114
+ it "accepts a hash with nil pattern values" do
115
+ expect { described_class.new(on: { StandardError => nil }) }.not_to raise_error
116
+ end
117
+
118
+ it "accepts a hash with Regexp pattern values" do
119
+ expect { described_class.new(on: { StandardError => /boom/ }) }.not_to raise_error
120
+ end
121
+
122
+ it "accepts a hash with Array-of-Regexp pattern values" do
123
+ expect { described_class.new(on: { StandardError => [/a/, /b/] }) }.not_to raise_error
124
+ end
125
+
126
+ it "rejects Object as on:" do
127
+ expect { described_class.new(on: Object) }
128
+ .to raise_error(ArgumentError, /on must be an Exception class/)
129
+ end
130
+
131
+ it "rejects Kernel as on:" do
132
+ expect { described_class.new(on: Kernel) }
133
+ .to raise_error(ArgumentError, /on must be an Exception class/)
134
+ end
135
+
136
+ it "rejects an array containing a non-Exception class" do
137
+ expect { described_class.new(on: [StandardError, Kernel]) }
138
+ .to raise_error(ArgumentError, /on must be an Exception class/)
139
+ end
140
+
141
+ it "rejects a hash key that is not an Exception class" do
142
+ expect { described_class.new(on: { Kernel => nil }) }
143
+ .to raise_error(ArgumentError, /on must be an Exception class/)
144
+ end
145
+
146
+ it "rejects a hash value that is a String" do
147
+ expect { described_class.new(on: { StandardError => "boom" }) }
148
+ .to raise_error(ArgumentError, /on\[StandardError\] must be nil, a Regexp, or an Array of Regexps/)
149
+ end
150
+
151
+ it "rejects a hash value that is an Array containing a non-Regexp" do
152
+ expect { described_class.new(on: { StandardError => [/a/, "b"] }) }
153
+ .to raise_error(ArgumentError, /on\[StandardError\] must be nil, a Regexp, or an Array of Regexps/)
154
+ end
155
+
156
+ it "rejects a string passed as on:" do
157
+ expect { described_class.new(on: "StandardError") }
158
+ .to raise_error(ArgumentError, /on must be an Exception class/)
159
+ end
160
+
161
+ it "validates on: even when intervals is provided" do
162
+ expect { described_class.new(intervals: [0.1], on: Object) }
163
+ .to raise_error(ArgumentError, /on must be an Exception class/)
164
+ end
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
59
183
  end
@@ -22,17 +22,19 @@ describe Retriable::ExponentialBackoff do
22
22
  end
23
23
 
24
24
  it "generates 10 randomized intervals" do
25
- expect(described_class.new(tries: 9).intervals).to eq([
26
- 0.5244067512211441,
27
- 0.9113920238761231,
28
- 1.2406087918999114,
29
- 1.7632403621664823,
30
- 2.338001204738311,
31
- 4.350816718580626,
32
- 5.339852157217869,
33
- 11.889873261212443,
34
- 18.756037881636484,
35
- ])
25
+ expect(described_class.new(tries: 9).intervals).to eq(
26
+ [
27
+ 0.5244067512211441,
28
+ 0.9113920238761231,
29
+ 1.2406087918999114,
30
+ 1.7632403621664823,
31
+ 2.338001204738311,
32
+ 4.350816718580626,
33
+ 5.339852157217869,
34
+ 11.889873261212443,
35
+ 18.756037881636484,
36
+ ],
37
+ )
36
38
  end
37
39
 
38
40
  it "generates defined number of intervals" do
@@ -40,19 +42,23 @@ describe Retriable::ExponentialBackoff do
40
42
  end
41
43
 
42
44
  it "generates intervals with a defined base interval" do
43
- expect(described_class.new(base_interval: 1).intervals).to eq([
44
- 1.0488135024422882,
45
- 1.8227840477522461,
46
- 2.4812175837998227,
47
- ])
45
+ expect(described_class.new(base_interval: 1).intervals).to eq(
46
+ [
47
+ 1.0488135024422882,
48
+ 1.8227840477522461,
49
+ 2.4812175837998227,
50
+ ],
51
+ )
48
52
  end
49
53
 
50
54
  it "generates intervals with a defined multiplier" do
51
- expect(described_class.new(multiplier: 1).intervals).to eq([
52
- 0.5244067512211441,
53
- 0.607594682584082,
54
- 0.5513816852888495,
55
- ])
55
+ expect(described_class.new(multiplier: 1).intervals).to eq(
56
+ [
57
+ 0.5244067512211441,
58
+ 0.607594682584082,
59
+ 0.5513816852888495,
60
+ ],
61
+ )
56
62
  end
57
63
 
58
64
  it "generates intervals with a defined max interval" do
@@ -60,15 +66,28 @@ describe Retriable::ExponentialBackoff do
60
66
  end
61
67
 
62
68
  it "generates intervals with a defined rand_factor" do
63
- expect(described_class.new(rand_factor: 0.2).intervals).to eq([
64
- 0.5097627004884576,
65
- 0.8145568095504492,
66
- 1.1712435167599646,
67
- ])
69
+ expect(described_class.new(rand_factor: 0.2).intervals).to eq(
70
+ [
71
+ 0.5097627004884576,
72
+ 0.8145568095504492,
73
+ 1.1712435167599646,
74
+ ],
75
+ )
68
76
  end
69
77
 
70
78
  it "generates 10 non-randomized intervals" do
71
79
  non_random_intervals = 9.times.inject([0.5]) { |memo, _i| memo + [memo.last * 1.5] }
72
80
  expect(described_class.new(tries: 10, rand_factor: 0.0).intervals).to eq(non_random_intervals)
73
81
  end
82
+
83
+ it "provides capped intervals lazily" do
84
+ interval_for = described_class.new(
85
+ base_interval: 1.0,
86
+ multiplier: 2.0,
87
+ max_interval: 4.0,
88
+ rand_factor: 0.0,
89
+ ).interval_provider
90
+
91
+ expect(Array.new(5) { |index| interval_for.call(index) }).to eq([1.0, 2.0, 4.0, 4.0, 4.0])
92
+ end
74
93
  end