Skip to content

Commit

Permalink
Allow setting no limit on the number of tries.
Browse files Browse the repository at this point in the history
Tests included.

Some refactoring required to implement this as current implementation's ExponentialBackoff class produced an array, and infinite retries are incompatible with a finite # of values. Changed code to support/use (lazy) enumerators instead.
  • Loading branch information
mvastola committed Jan 24, 2021
1 parent 572f9e1 commit 4f605bf
Show file tree
Hide file tree
Showing 4 changed files with 101 additions and 25 deletions.
41 changes: 30 additions & 11 deletions lib/retriable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,20 +42,21 @@ def retriable(opts = {})
elapsed_time = -> { Time.now - start_time }

if intervals
tries = intervals.size + 1
tries = intervals.size + 1 if intervals.size
else
intervals = ExponentialBackoff.new(
tries: tries - 1,
backoff = ExponentialBackoff.new(
tries: tries ? tries - 1 : nil,
base_interval: base_interval,
multiplier: multiplier,
max_interval: max_interval,
rand_factor: rand_factor,
).intervals
)
intervals = backoff.intervals
end

tries.times do |index|
try = index + 1

# TODO: Ideally this would be it's own function, but would probably require
# a separate class to efficiently pass on the processed config
run_try = Proc.new do |interval, try|
begin
return Timeout.timeout(timeout) { return yield(try) } if timeout
return yield(try)
Expand All @@ -65,12 +66,30 @@ def retriable(opts = {})
exception.is_a?(e) && ([*on[e]].empty? || [*on[e]].any? { |pattern| exception.message =~ pattern })
end
end

interval = intervals[index]
on_retry.call(exception, try, elapsed_time.call, interval) if on_retry
raise if try >= tries || (elapsed_time.call + interval) > max_elapsed_time
sleep interval if sleep_disabled != true


# Note: Tries can't always be calculated if a custom Enumerator is given
# for the intervals argument. (Enumerator#size will return nil, per docs)
# So we'll just let the enumerator run out, and then invoke run_try once more.
# With a nil timeout.
raise unless interval
raise if max_elapsed_time && elapsed_time.call + interval > max_elapsed_time
sleep interval unless sleep_disabled
throw :failed
end
end

try = 0
intervals.each do |interval|
try += 1
# Use throw/catch to distinguish between success and caught exception
catch :failed do
result = run_try.call(interval, try)
return result
end
break if tries and try + 1 >= tries
end
run_try.call(nil, try + 1)
end
end
18 changes: 11 additions & 7 deletions lib/retriable/exponential_backoff.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,22 @@ def initialize(opts = {})
end

def intervals
intervals = Array.new(tries) do |iteration|
[base_interval * multiplier**iteration, max_interval].min
end

return intervals if rand_factor.zero?

intervals.map { |i| randomize(i) }
Enumerator.new(tries) do |result|
try = 0
loop do
interval = [base_interval * multiplier**try, max_interval].min
result << randomize(interval)
try += 1
raise StopIteration if tries && try >= tries
end
end.lazy
end

private

def randomize(interval)
return interval if rand_factor.zero?

delta = rand_factor * interval * 1.0
min = interval - delta
max = interval + delta
Expand Down
12 changes: 6 additions & 6 deletions spec/exponential_backoff_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
end

it "generates 10 randomized intervals" do
expect(described_class.new(tries: 9).intervals).to eq([
expect(described_class.new(tries: 9).intervals.to_a).to eq([
0.5244067512211441,
0.9113920238761231,
1.2406087918999114,
Expand All @@ -38,27 +38,27 @@
end

it "generates intervals with a defined base interval" do
expect(described_class.new(base_interval: 1).intervals).to eq([
expect(described_class.new(base_interval: 1).intervals.to_a).to eq([
1.0488135024422882,
1.8227840477522461,
2.4812175837998227
])
end

it "generates intervals with a defined multiplier" do
expect(described_class.new(multiplier: 1).intervals).to eq([
expect(described_class.new(multiplier: 1).intervals.to_a).to eq([
0.5244067512211441,
0.607594682584082,
0.5513816852888495
])
end

it "generates intervals with a defined max interval" do
expect(described_class.new(max_interval: 1.0, rand_factor: 0.0).intervals).to eq([0.5, 0.75, 1.0])
expect(described_class.new(max_interval: 1.0, rand_factor: 0.0).intervals.to_a).to eq([0.5, 0.75, 1.0])
end

it "generates intervals with a defined rand_factor" do
expect(described_class.new(rand_factor: 0.2).intervals).to eq([
expect(described_class.new(rand_factor: 0.2).intervals.to_a).to eq([
0.5097627004884576,
0.8145568095504492,
1.1712435167599646
Expand All @@ -67,6 +67,6 @@

it "generates 10 non-randomized intervals" do
non_random_intervals = 9.times.inject([0.5]) { |memo, _i| memo + [memo.last * 1.5] }
expect(described_class.new(tries: 10, rand_factor: 0.0).intervals).to eq(non_random_intervals)
expect(described_class.new(tries: 10, rand_factor: 0.0).intervals.to_a).to eq(non_random_intervals)
end
end
55 changes: 54 additions & 1 deletion spec/retriable_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,59 @@ def increment_tries_with_exception(exception_class = nil)
end
end

context "with interval enumerator" do
let(:intervals_with_size) do
Enumerator.new(4) { |result| loop { result << 0.00001 } }
end
let(:intervals_with_one_result) do
Enumerator.new(4) { |result| loop { result << 0.00001; raise StopIteration } }
end

it "uses size of Enumerator if it can be determined without iterating" do
expect do
described_class.retriable(on: StandardError, tries: 6, intervals: intervals_with_size, max_elapsed_time: 1.2) do
increment_tries_with_exception
end
end.to raise_error(StandardError)
expect(@tries).to eq(5)
end

it "recognizes if Enumerator stops iterating" do
expect do
described_class.retriable(on: StandardError, tries: 6, intervals: intervals_with_one_result, max_elapsed_time: 1.2) do
increment_tries_with_exception
end
end.to raise_error(StandardError)
expect(@tries).to eq(2)
end

it "lazily iterates through the enumerator" do
start_time = Time.now
intervals = Enumerator.new { |result| loop { result << 0.00001; sleep 0.2 } }
expect do
described_class.retriable(on: StandardError, tries: 6, intervals: intervals, max_elapsed_time: 1.2) do
increment_tries_with_exception
end
end.to raise_error(StandardError)
expect(@tries).to eq(6)
expect(Time.now - start_time).to be < 2
end
end

context "with unlimited tries" do
let(:args) { { on: StandardError, tries: nil, rand_factor: 0.0, multiplier: 1, base_interval: 0.00001 } }

it "keeps going indefinitely" do
start_time = Time.now.to_i
expect do
described_class.retriable(args) do
increment_tries_with_exception(NonStandardError) if start_time + 3 < Time.now.to_i
increment_tries_with_exception
end
end.to raise_error(NonStandardError)
expect(@tries).to be > 100
end
end
context "with an array :on parameter" do
it "handles both kinds of exceptions" do
described_class.retriable(on: [StandardError, NonStandardError]) do
Expand Down Expand Up @@ -211,7 +264,7 @@ def increment_tries_with_exception(exception_class = nil)
described_class.configure { |c| c.sleep_disabled = false }

expect do
described_class.retriable(base_interval: 1.0, multiplier: 1.0, rand_factor: 0.0, max_elapsed_time: 2.0) do
described_class.retriable(base_interval: 1.0, multiplier: 1.0, rand_factor: 0.0, max_elapsed_time: 2) do
increment_tries_with_exception
end
end.to raise_error(StandardError)
Expand Down

0 comments on commit 4f605bf

Please sign in to comment.