retriable 3.5.0 → 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,18 +25,22 @@ module Retriable
16
25
  @config ||= Config.new
17
26
  end
18
27
 
19
- def override(opts = {})
20
- raise ArgumentError, "empty override options are not allowed; use reset_override instead" if opts.empty?
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?
21
31
 
22
32
  validate_override_options(opts)
23
- @override_config = opts
24
- end
25
33
 
26
- def reset_override
27
- @override_config = nil
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
28
41
  end
29
42
 
30
- def with_context(context_key, options = {}, &block)
43
+ def with_context(context_key, options = {}, &)
31
44
  contexts = available_contexts
32
45
 
33
46
  if !contexts.key?(context_key)
@@ -37,22 +50,25 @@ module Retriable
37
50
 
38
51
  return unless block_given?
39
52
 
40
- retriable(context_options_for(context_key, options), &block)
53
+ retriable(context_options_for(context_key, options), &)
41
54
  end
42
55
 
43
- def retriable(opts = {}, &block)
44
- local_config = if opts.empty? && !@override_config
56
+ def retriable(opts = {}, &)
57
+ override_config = current_override
58
+ local_config = if opts.empty? && !override_config
45
59
  config
46
60
  else
47
- 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))
48
62
  end
49
63
 
50
- tries = local_config.tries
51
- intervals = build_intervals(local_config, tries)
52
- timeout = local_config.timeout
64
+ # Config is mutable through `configure`, so validate again immediately before use.
65
+ local_config.validate!
66
+
67
+ plan = retry_plan(local_config)
53
68
  on = local_config.on
54
69
  retry_if = local_config.retry_if
55
70
  on_retry = local_config.on_retry
71
+ on_give_up = local_config.on_give_up
56
72
  sleep_disabled = local_config.sleep_disabled
57
73
  max_elapsed_time = local_config.max_elapsed_time
58
74
 
@@ -61,55 +77,64 @@ module Retriable
61
77
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
62
78
  elapsed_time = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time }
63
79
 
64
- tries = intervals.size + 1
65
-
66
80
  execute_tries(
67
- tries: tries, intervals: intervals, timeout: timeout,
81
+ max_tries: plan.max_tries, interval_for: plan.interval_for,
68
82
  exception_list: exception_list, on: on, retry_if: retry_if, on_retry: on_retry,
69
- elapsed_time: elapsed_time, max_elapsed_time: max_elapsed_time,
70
- 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, &
71
85
  )
72
86
  end
73
87
 
74
88
  def execute_tries( # rubocop:disable Metrics/ParameterLists
75
- tries:, intervals:, timeout:, exception_list:,
76
- 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:
77
91
  )
78
- tries.times do |index|
79
- try = index + 1
80
-
92
+ try = 0
93
+ loop do
94
+ try += 1
81
95
  begin
82
- return call_with_timeout(timeout, try, &block)
96
+ return yield(try)
83
97
  rescue *exception_list => e
84
98
  raise unless retriable_exception?(e, on, exception_list, retry_if)
85
99
 
86
- interval = intervals[index]
100
+ interval = interval_for.call(try - 1)
87
101
  call_on_retry(on_retry, e, try, elapsed_time.call, interval)
88
102
 
89
103
  elapsed_interval = sleep_disabled == true ? 0 : interval
90
- raise unless can_retry?(try, tries, elapsed_time.call, elapsed_interval, max_elapsed_time)
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
91
111
 
92
112
  sleep interval if sleep_disabled != true
93
113
  end
94
114
  end
95
115
  end
96
116
 
97
- def build_intervals(local_config, tries)
98
- 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)
99
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)
127
+
128
+ RetryPlan.new(max_tries, ->(index) { index < max_tries - 1 ? provider.call(index) : nil })
129
+ end
130
+
131
+ def interval_provider(local_config)
100
132
  ExponentialBackoff.new(
101
- tries: tries - 1,
102
133
  base_interval: local_config.base_interval,
103
134
  multiplier: local_config.multiplier,
104
135
  max_interval: local_config.max_interval,
105
136
  rand_factor: local_config.rand_factor,
106
- ).intervals
107
- end
108
-
109
- def call_with_timeout(timeout, try)
110
- return Timeout.timeout(timeout) { yield(try) } if timeout
111
-
112
- yield(try)
137
+ ).interval_provider
113
138
  end
114
139
 
115
140
  def call_on_retry(on_retry, exception, try, elapsed_time, interval)
@@ -118,11 +143,24 @@ module Retriable
118
143
  on_retry.call(exception, try, elapsed_time, interval)
119
144
  end
120
145
 
121
- def can_retry?(try, tries, elapsed_time, interval, max_elapsed_time)
122
- return false unless try < tries
123
- 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
124
150
 
125
- (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
126
164
  end
127
165
 
128
166
  # When `on` is a Hash, we need to verify the exception matches a pattern.
@@ -151,16 +189,23 @@ module Retriable
151
189
  raise ArgumentError, "#{k} is not a valid option" unless Config::ATTRIBUTES.include?(k)
152
190
  end
153
191
 
192
+ return unless opts.key?(:contexts)
193
+
154
194
  contexts = opts[:contexts]
155
- return unless contexts.is_a?(Hash)
195
+ return if contexts.nil?
196
+
197
+ raise ArgumentError, "contexts must be a Hash or nil, got #{contexts.inspect}" unless contexts.is_a?(Hash)
156
198
 
157
- contexts.each_value do |context_options|
158
- validate_context_override_options(context_options)
199
+ contexts.each do |context_key, context_options|
200
+ validate_context_override_options(context_key, context_options)
159
201
  end
160
202
  end
161
203
 
162
- def validate_context_override_options(context_options)
163
- return unless context_options.is_a?(Hash)
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
164
209
 
165
210
  context_attributes = Config::ATTRIBUTES - [:contexts]
166
211
  context_options.each_key do |k|
@@ -171,9 +216,17 @@ module Retriable
171
216
  def apply_override_options(options, overrides)
172
217
  return options unless overrides
173
218
 
174
- options = options.merge(overrides)
175
- options[:intervals] = nil if overrides.key?(:tries) && !overrides.key?(:intervals)
176
- options
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
177
230
  end
178
231
 
179
232
  def available_contexts
@@ -183,7 +236,7 @@ module Retriable
183
236
  def context_options_for(context_key, options)
184
237
  context_options = config_contexts.fetch(context_key, {})
185
238
  context_options = {} unless context_options.is_a?(Hash)
186
- context_options = context_options.merge(options)
239
+ context_options = merge_layer(context_options, options)
187
240
 
188
241
  override_context_options = override_contexts[context_key]
189
242
  return context_options unless override_context_options.is_a?(Hash)
@@ -196,24 +249,32 @@ module Retriable
196
249
  end
197
250
 
198
251
  def override_contexts
199
- contexts = @override_config && @override_config[:contexts]
252
+ override_config = current_override
253
+ contexts = override_config && override_config[:contexts]
200
254
  contexts.is_a?(Hash) ? contexts : {}
201
255
  end
202
256
 
257
+ def current_override
258
+ Thread.current.thread_variable_get(OVERRIDE_THREAD_KEY)
259
+ end
260
+
203
261
  private_class_method(
204
262
  :validate_override_options,
205
263
  :validate_context_override_options,
206
264
  :execute_tries,
207
- :build_intervals,
208
- :call_with_timeout,
265
+ :retry_plan,
266
+ :interval_provider,
209
267
  :call_on_retry,
210
- :can_retry?,
268
+ :call_on_give_up,
269
+ :retry_stop_reason,
211
270
  :retriable_exception?,
212
271
  :hash_exception_match?,
213
272
  :apply_override_options,
273
+ :merge_layer,
214
274
  :available_contexts,
215
275
  :context_options_for,
216
276
  :config_contexts,
217
277
  :override_contexts,
278
+ :current_override,
218
279
  )
219
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