Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# HEAD

- 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

Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down Expand Up @@ -202,6 +202,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:
Expand Down
55 changes: 42 additions & 13 deletions lib/retriable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,49 +27,71 @@ def with_context(context_key, options = {}, &block)
retriable(config.contexts[context_key].merge(options), &block)
end

def retriable(opts = {}, &block)
def retriable(opts = {}, &block) # rubocop:disable Metrics/PerceivedComplexity
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/CyclomaticComplexity: Cyclomatic complexity for retriable is too high. [7/6]

local_config = opts.empty? ? config : Config.new(config.to_h.merge(opts))

tries = local_config.tries
intervals = build_intervals(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
Comment on lines +47 to +52
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In tries: :infinite mode with custom intervals, only the empty-array case is validated. If intervals contains nil or non-numeric values, interval_for can return a non-numeric interval and can_retry? will raise a TypeError when evaluating elapsed_time + interval. Consider validating that all interval values (and especially the last value, since it is reused) are Numeric, and raising a clear ArgumentError if not (and adding a spec for this behavior).

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This multiline ExponentialBackoff.new(...) call is missing a trailing comma after the last argument. The repo’s RuboCop config enforces trailing commas for multiline argument lists (Style/TrailingCommaInArguments), so this will fail linting.

Suggested change
max_interval: local_config.max_interval, rand_factor: local_config.rand_factor
max_interval: local_config.max_interval, rand_factor: local_config.rand_factor,

Copilot uses AI. Check for mistakes.
)
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 = Time.now
elapsed_time = -> { Time.now - 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
)
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)

raise unless can_retry?(try, tries, elapsed_time.call, interval, max_elapsed_time)
raise unless can_retry?(try, max_tries, elapsed_time.call, interval, max_elapsed_time)

sleep interval if sleep_disabled != true
end
Expand Down Expand Up @@ -100,8 +122,14 @@ 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)
try < tries && (elapsed_time + interval) <= max_elapsed_time
def can_retry?(try, max_tries, elapsed_time, interval, max_elapsed_time)
return false if max_tries && try >= max_tries

(elapsed_time + interval) <= max_elapsed_time
Comment thread
kamui marked this conversation as resolved.
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.
Expand Down Expand Up @@ -131,6 +159,7 @@ def hash_exception_match?(exception, on, exception_list)
:call_with_timeout,
:call_on_retry,
:can_retry?,
:finite_number?,
:retriable_exception?,
:hash_exception_match?,
)
Expand Down
11 changes: 6 additions & 5 deletions lib/retriable/exponential_backoff.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,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
Expand Down
58 changes: 58 additions & 0 deletions spec/retriable_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,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
Expand Down Expand Up @@ -182,6 +230,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
Expand Down