From b86f160441ee2fcaa2abe87071736bfa96083c33 Mon Sep 17 00:00:00 2001 From: Jack Chu Date: Fri, 6 Mar 2026 22:24:00 -0500 Subject: [PATCH 1/5] Add opt-in infinite retries via tries: :infinite Support infinite retry loops bounded by max_elapsed_time, useful for long-running worker processes. Raises ArgumentError if max_elapsed_time is not finite or if custom intervals are empty. Extracts interval_for on ExponentialBackoff for lazy per-attempt interval computation without pre-allocating an array. --- CHANGELOG.md | 1 + README.md | 15 ++++++- lib/retriable.rb | 51 ++++++++++++++++++------ lib/retriable/exponential_backoff.rb | 11 +++--- spec/retriable_spec.rb | 58 ++++++++++++++++++++++++++++ 5 files changed, 118 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a4c8ff..a254b03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ ## 3.4.0 - Add `retry_if` option to support custom retry predicates, including checks against wrapped `exception.cause` values. +- Add opt-in infinite retries via `tries: :infinite`, requiring a finite `max_elapsed_time` safety bound. ## 3.3.0 diff --git a/README.md b/README.md index cbbce5d..becf2a1 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Here are the available options, in some vague order of relevance to most common | Option | Default | Definition | | ---------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`tries`** | `3` | Number of attempts to make at running your code block (includes initial attempt). | +| **`tries`** | `3` | Number of attempts to make at running your code block (includes initial attempt). Pass `:infinite` to keep retrying until success or until `max_elapsed_time` is reached. | | **`on`** | `[StandardError]` | Type of exceptions to retry. [Read more](#configuring-which-options-to-retry-with-on). | | **`retry_if`** | `nil` | Callable (for example a `Proc` or lambda) that receives the rescued exception and returns true/false to decide whether to retry. [Read more](#advanced-retry-matching-with-retry_if). | | **`on_retry`** | `nil` | `Proc` to call after each try is rescued. [Read more](#callbacks). | @@ -233,6 +233,19 @@ end This example makes 5 total attempts. If the first attempt fails, the 2nd attempt occurs 0.5 seconds later. +### Infinite Retries (Opt-in) + +You can opt in to infinite retries with `tries: :infinite`. This is useful for long-running worker processes where retrying should continue until success, but it should be used carefully. + +```ruby +Retriable.retriable(tries: :infinite, max_elapsed_time: 300) do + # code here... +end +``` + +`max_elapsed_time` must be a finite number when using `tries: :infinite`. +Retriable raises `ArgumentError` if `max_elapsed_time` is unbounded. + ### Turn off Exponential Backoff Exponential backoff is enabled by default. If you want to simply retry code every second, 5 times maximum, you can do this: diff --git a/lib/retriable.rb b/lib/retriable.rb index f1ea6df..41ea60a 100644 --- a/lib/retriable.rb +++ b/lib/retriable.rb @@ -50,7 +50,6 @@ def retriable(opts = {}, &block) local_config.validate! tries = local_config.tries - intervals = build_intervals(local_config, tries) timeout = local_config.timeout on = local_config.on retry_if = local_config.retry_if @@ -58,15 +57,38 @@ def retriable(opts = {}, &block) sleep_disabled = local_config.sleep_disabled max_elapsed_time = local_config.max_elapsed_time + if tries == :infinite + unless finite_number?(max_elapsed_time) + raise ArgumentError, + "max_elapsed_time must be finite when tries is :infinite" + end + + if local_config.intervals + raise ArgumentError, "intervals must not be empty for infinite retries" if local_config.intervals.empty? + + custom = local_config.intervals + interval_for = ->(i) { custom[[i, custom.size - 1].min] } + else + backoff = ExponentialBackoff.new( + base_interval: local_config.base_interval, multiplier: local_config.multiplier, + max_interval: local_config.max_interval, rand_factor: local_config.rand_factor + ) + interval_for = ->(i) { backoff.interval_for(i) } + end + max_tries = nil + else + intervals = build_intervals(local_config, tries) + max_tries = intervals.size + 1 + interval_for = ->(i) { intervals[i] } + end + exception_list = on.is_a?(Hash) ? on.keys : on exception_list = [*exception_list] start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) elapsed_time = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time } - tries = intervals.size + 1 - execute_tries( - tries: tries, intervals: intervals, timeout: timeout, + max_tries: max_tries, interval_for: interval_for, timeout: timeout, exception_list: exception_list, on: on, retry_if: retry_if, on_retry: on_retry, elapsed_time: elapsed_time, max_elapsed_time: max_elapsed_time, sleep_disabled: sleep_disabled, &block @@ -74,22 +96,22 @@ def retriable(opts = {}, &block) end def execute_tries( # rubocop:disable Metrics/ParameterLists - tries:, intervals:, timeout:, exception_list:, + max_tries:, interval_for:, timeout:, exception_list:, on:, retry_if:, on_retry:, elapsed_time:, max_elapsed_time:, sleep_disabled:, &block ) - tries.times do |index| - try = index + 1 - + try = 0 + loop do + try += 1 begin return call_with_timeout(timeout, try, &block) rescue *exception_list => e raise unless retriable_exception?(e, on, exception_list, retry_if) - interval = intervals[index] + interval = interval_for.call(try - 1) call_on_retry(on_retry, e, try, elapsed_time.call, interval) elapsed_interval = sleep_disabled == true ? 0 : interval - raise unless can_retry?(try, tries, elapsed_time.call, elapsed_interval, max_elapsed_time) + raise unless can_retry?(try, max_tries, elapsed_time.call, elapsed_interval, max_elapsed_time) sleep interval if sleep_disabled != true end @@ -120,13 +142,17 @@ def call_on_retry(on_retry, exception, try, elapsed_time, interval) on_retry.call(exception, try, elapsed_time, interval) end - def can_retry?(try, tries, elapsed_time, interval, max_elapsed_time) - return false unless try < tries + def can_retry?(try, max_tries, elapsed_time, interval, max_elapsed_time) + return false if max_tries && try >= max_tries return true if max_elapsed_time.nil? (elapsed_time + interval) <= max_elapsed_time end + def finite_number?(value) + value.is_a?(Numeric) && (!value.respond_to?(:finite?) || value.finite?) + end + # When `on` is a Hash, we need to verify the exception matches a pattern. # For any non-Hash `on` value (e.g., Array of classes, single Exception class, # or Module), the `rescue *exception_list` clause already guarantees the @@ -210,6 +236,7 @@ def override_contexts :call_with_timeout, :call_on_retry, :can_retry?, + :finite_number?, :retriable_exception?, :hash_exception_match?, :apply_override_options, diff --git a/lib/retriable/exponential_backoff.rb b/lib/retriable/exponential_backoff.rb index f9bd7d1..d32a6f6 100644 --- a/lib/retriable/exponential_backoff.rb +++ b/lib/retriable/exponential_backoff.rb @@ -29,13 +29,14 @@ def initialize(opts = {}) end def intervals - intervals = Array.new(tries) do |iteration| - [base_interval * (multiplier**iteration), max_interval].min - end + Array.new(tries) { |iteration| interval_for(iteration) } + end - return intervals if rand_factor.zero? + def interval_for(iteration) + interval = [base_interval * (multiplier**iteration), max_interval].min + return interval if rand_factor.zero? - intervals.map { |i| randomize(i) } + randomize(interval) end private diff --git a/spec/retriable_spec.rb b/spec/retriable_spec.rb index 979c8ff..f9ddbe8 100644 --- a/spec/retriable_spec.rb +++ b/spec/retriable_spec.rb @@ -84,6 +84,54 @@ def increment_tries_with_exception(exception_class = nil) expect(@tries).to eq(10) end + it "supports infinite retries until the block succeeds" do + described_class.retriable(tries: :infinite) do + increment_tries + raise StandardError if @tries < 5 + end + + expect(@tries).to eq(5) + end + + it "stops infinite retries at max_elapsed_time" do + start_time = Time.now + timeline = [ + start_time, + start_time, + start_time, + start_time + 0.01, + start_time + 0.01, + ] + allow(Time).to receive(:now) { timeline.shift || timeline.last } + + expect do + described_class.retriable( + tries: :infinite, + base_interval: 0.01, + multiplier: 1.0, + rand_factor: 0.0, + sleep_disabled: true, + max_elapsed_time: 0.015, + ) do + increment_tries_with_exception + end + end.to raise_error(StandardError) + + expect(@tries).to eq(2) + end + + it "raises ArgumentError for infinite retries without a finite max_elapsed_time" do + expect do + described_class.retriable(tries: :infinite, max_elapsed_time: Float::INFINITY) { increment_tries } + end.to raise_error(ArgumentError, /max_elapsed_time/) + end + + it "raises ArgumentError for infinite retries with empty intervals" do + expect do + described_class.retriable(tries: :infinite, intervals: []) { increment_tries_with_exception } + end.to raise_error(ArgumentError, /intervals/) + end + it "will timeout after 1 second" do expect { described_class.retriable(timeout: 1) { sleep(1.1) } }.to raise_error(Timeout::Error) end @@ -195,6 +243,16 @@ def increment_tries_with_exception(exception_class = nil) expect(@next_interval_table[3]).to eq(0.3) expect(@next_interval_table[4]).to be_nil end + + it "allows non-integer tries when intervals are provided" do + expect do + described_class.retriable(intervals: [0.1], tries: :ignored) do + increment_tries_with_exception + end + end.to raise_error(StandardError) + + expect(@tries).to eq(2) + end end context "with an array :on parameter" do From 42b5023454c1b5ab43d0825492b12eb6f67969a2 Mon Sep 17 00:00:00 2001 From: Jack Chu Date: Fri, 22 May 2026 23:29:01 -0400 Subject: [PATCH 2/5] Refine infinite retry interval planning --- lib/retriable.rb | 63 ++++++++++++++++------------ lib/retriable/exponential_backoff.rb | 17 ++++++++ spec/exponential_backoff_spec.rb | 7 ++++ spec/retriable_spec.rb | 4 +- 4 files changed, 62 insertions(+), 29 deletions(-) diff --git a/lib/retriable.rb b/lib/retriable.rb index 41ea60a..e742209 100644 --- a/lib/retriable.rb +++ b/lib/retriable.rb @@ -48,39 +48,13 @@ def retriable(opts = {}, &block) end local_config.validate! - - tries = local_config.tries timeout = local_config.timeout on = local_config.on retry_if = local_config.retry_if on_retry = local_config.on_retry sleep_disabled = local_config.sleep_disabled max_elapsed_time = local_config.max_elapsed_time - - if tries == :infinite - unless finite_number?(max_elapsed_time) - raise ArgumentError, - "max_elapsed_time must be finite when tries is :infinite" - end - - if local_config.intervals - raise ArgumentError, "intervals must not be empty for infinite retries" if local_config.intervals.empty? - - custom = local_config.intervals - interval_for = ->(i) { custom[[i, custom.size - 1].min] } - else - backoff = ExponentialBackoff.new( - base_interval: local_config.base_interval, multiplier: local_config.multiplier, - max_interval: local_config.max_interval, rand_factor: local_config.rand_factor - ) - interval_for = ->(i) { backoff.interval_for(i) } - end - max_tries = nil - else - intervals = build_intervals(local_config, tries) - max_tries = intervals.size + 1 - interval_for = ->(i) { intervals[i] } - end + max_tries, interval_for = retry_plan(local_config) exception_list = on.is_a?(Hash) ? on.keys : on exception_list = [*exception_list] @@ -130,6 +104,38 @@ def build_intervals(local_config, tries) ).intervals end + def retry_plan(local_config) + return infinite_retry_plan(local_config) if local_config.tries == :infinite + + intervals = build_intervals(local_config, local_config.tries) + [intervals.size + 1, ->(i) { intervals[i] }] + end + + def infinite_retry_plan(local_config) + unless finite_number?(local_config.max_elapsed_time) + raise ArgumentError, + "max_elapsed_time must be finite when tries is :infinite" + end + + [nil, infinite_interval_provider(local_config)] + end + + def infinite_interval_provider(local_config) + if local_config.intervals + raise ArgumentError, "intervals must not be empty for infinite retries" if local_config.intervals.empty? + + custom = local_config.intervals + return ->(i) { custom.fetch(i) { custom.last } } + end + + ExponentialBackoff.new( + base_interval: local_config.base_interval, + multiplier: local_config.multiplier, + max_interval: local_config.max_interval, + rand_factor: local_config.rand_factor, + ).interval_provider + end + def call_with_timeout(timeout, try) return Timeout.timeout(timeout) { yield(try) } if timeout @@ -233,6 +239,9 @@ def override_contexts :validate_context_override_options, :execute_tries, :build_intervals, + :retry_plan, + :infinite_retry_plan, + :infinite_interval_provider, :call_with_timeout, :call_on_retry, :can_retry?, diff --git a/lib/retriable/exponential_backoff.rb b/lib/retriable/exponential_backoff.rb index d32a6f6..f936693 100644 --- a/lib/retriable/exponential_backoff.rb +++ b/lib/retriable/exponential_backoff.rb @@ -39,6 +39,17 @@ def interval_for(iteration) randomize(interval) end + def interval_provider + raw_interval = base_interval + + lambda do |_iteration| + interval = [raw_interval, max_interval].min + raw_interval = next_raw_interval(raw_interval) + + rand_factor.zero? ? interval : randomize(interval) + end + end + private def validate! @@ -71,6 +82,12 @@ def finite_number?(value) value.is_a?(Numeric) && value.to_f.finite? end + def next_raw_interval(raw_interval) + return max_interval if multiplier >= 1 && raw_interval >= max_interval + + raw_interval * multiplier + end + def randomize(interval) delta = rand_factor * interval.to_f min = interval - delta diff --git a/spec/exponential_backoff_spec.rb b/spec/exponential_backoff_spec.rb index 6bf7a41..c22b779 100644 --- a/spec/exponential_backoff_spec.rb +++ b/spec/exponential_backoff_spec.rb @@ -71,4 +71,11 @@ 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) end + + it "provides capped intervals lazily" do + interval_for = described_class.new(base_interval: 1.0, multiplier: 2.0, max_interval: 4.0, rand_factor: 0.0) + .interval_provider + + expect(5.times.map { |index| interval_for.call(index) }).to eq([1.0, 2.0, 4.0, 4.0, 4.0]) + end end diff --git a/spec/retriable_spec.rb b/spec/retriable_spec.rb index f9ddbe8..d3e9eca 100644 --- a/spec/retriable_spec.rb +++ b/spec/retriable_spec.rb @@ -94,7 +94,7 @@ def increment_tries_with_exception(exception_class = nil) end it "stops infinite retries at max_elapsed_time" do - start_time = Time.now + start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) timeline = [ start_time, start_time, @@ -102,7 +102,7 @@ def increment_tries_with_exception(exception_class = nil) start_time + 0.01, start_time + 0.01, ] - allow(Time).to receive(:now) { timeline.shift || timeline.last } + allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC) { timeline.shift || timeline.last } expect do described_class.retriable( From 285159e35d2533ac84775333124878da11802357 Mon Sep 17 00:00:00 2001 From: Jack Chu Date: Fri, 22 May 2026 23:31:53 -0400 Subject: [PATCH 3/5] Fix backoff spec lint --- spec/exponential_backoff_spec.rb | 46 ++++++++++++++++---------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/spec/exponential_backoff_spec.rb b/spec/exponential_backoff_spec.rb index c22b779..bc6801d 100644 --- a/spec/exponential_backoff_spec.rb +++ b/spec/exponential_backoff_spec.rb @@ -23,16 +23,16 @@ it "generates 10 randomized intervals" do expect(described_class.new(tries: 9).intervals).to eq([ - 0.5244067512211441, - 0.9113920238761231, - 1.2406087918999114, - 1.7632403621664823, - 2.338001204738311, - 4.350816718580626, - 5.339852157217869, - 11.889873261212443, - 18.756037881636484, - ]) + 0.5244067512211441, + 0.9113920238761231, + 1.2406087918999114, + 1.7632403621664823, + 2.338001204738311, + 4.350816718580626, + 5.339852157217869, + 11.889873261212443, + 18.756037881636484, + ]) end it "generates defined number of intervals" do @@ -41,18 +41,18 @@ it "generates intervals with a defined base interval" do expect(described_class.new(base_interval: 1).intervals).to eq([ - 1.0488135024422882, - 1.8227840477522461, - 2.4812175837998227, - ]) + 1.0488135024422882, + 1.8227840477522461, + 2.4812175837998227, + ]) end it "generates intervals with a defined multiplier" do expect(described_class.new(multiplier: 1).intervals).to eq([ - 0.5244067512211441, - 0.607594682584082, - 0.5513816852888495, - ]) + 0.5244067512211441, + 0.607594682584082, + 0.5513816852888495, + ]) end it "generates intervals with a defined max interval" do @@ -61,10 +61,10 @@ it "generates intervals with a defined rand_factor" do expect(described_class.new(rand_factor: 0.2).intervals).to eq([ - 0.5097627004884576, - 0.8145568095504492, - 1.1712435167599646, - ]) + 0.5097627004884576, + 0.8145568095504492, + 1.1712435167599646, + ]) end it "generates 10 non-randomized intervals" do @@ -76,6 +76,6 @@ interval_for = described_class.new(base_interval: 1.0, multiplier: 2.0, max_interval: 4.0, rand_factor: 0.0) .interval_provider - expect(5.times.map { |index| interval_for.call(index) }).to eq([1.0, 2.0, 4.0, 4.0, 4.0]) + expect(Array.new(5) { |index| interval_for.call(index) }).to eq([1.0, 2.0, 4.0, 4.0, 4.0]) end end From 5aa8a14be230c7fe3e1de0bfc56cacd17945e1fd Mon Sep 17 00:00:00 2001 From: Jack Chu Date: Sat, 23 May 2026 13:03:04 -0400 Subject: [PATCH 4/5] Align infinite retry elapsed spec with main --- spec/retriable_spec.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/retriable_spec.rb b/spec/retriable_spec.rb index d3e9eca..743073c 100644 --- a/spec/retriable_spec.rb +++ b/spec/retriable_spec.rb @@ -101,6 +101,8 @@ def increment_tries_with_exception(exception_class = nil) start_time, start_time + 0.01, start_time + 0.01, + start_time + 0.02, + start_time + 0.02, ] allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC) { timeline.shift || timeline.last } @@ -117,7 +119,7 @@ def increment_tries_with_exception(exception_class = nil) end end.to raise_error(StandardError) - expect(@tries).to eq(2) + expect(@tries).to eq(3) end it "raises ArgumentError for infinite retries without a finite max_elapsed_time" do From 6cf2c6f1db1d3eec25b06fd5b293826718abf9fe Mon Sep 17 00:00:00 2001 From: Jack Chu Date: Sat, 23 May 2026 13:19:42 -0400 Subject: [PATCH 5/5] Allow infinite tries through config validation --- lib/retriable/config.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/retriable/config.rb b/lib/retriable/config.rb index 48fb514..031dc7e 100644 --- a/lib/retriable/config.rb +++ b/lib/retriable/config.rb @@ -54,8 +54,13 @@ def validate! validate_optional_non_negative_number(:timeout, timeout) validate_intervals return if intervals + return validate_backoff_options if tries == :infinite validate_positive_integer(:tries, tries) + validate_backoff_options + end + + def validate_backoff_options validate_non_negative_number(:base_interval, base_interval) validate_non_negative_number(:multiplier, multiplier) validate_non_negative_number(:max_interval, max_interval)