Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
- Custom renderer support by [@anuj-pal27](https://github.com/anuj-pal27) (#244).
- [SSE] Add graceful shutdown support for SSE streams by [@tmchow](https://github.com/tmchow) (#261).

### Changed

- [Deferred] Increase default retry limit to 20 and update default retry backoff to `(attempt**4) + 10 + (rand(15) * attempt)` by [@anuj-pal27](https://github.com/anuj-pal27) (#251).

## [1.22.1] - 2026-04-01

### Fixed
Expand Down
8 changes: 8 additions & 0 deletions lib/rage/deferred/metadata.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ def will_retry?
!!task.__next_retry_in(attempts, nil)
end

# Returns the number of seconds until the next retry, or `nil` if no retry will occur.
# The result is memoized per attempt so that the value reported here matches what the queue uses to schedule the retry.
# @return [Numeric, nil] retry delay in seconds, or `nil` if the task won't be retried
def will_retry_in
task = Rage::Deferred::Context.get_task(context)
task.__next_retry_in(attempts, nil)
end

private

def context
Expand Down
30 changes: 21 additions & 9 deletions lib/rage/deferred/task.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,16 @@
# ```
#
module Rage::Deferred::Task
MAX_ATTEMPTS = 5
MAX_ATTEMPTS = 20
private_constant :MAX_ATTEMPTS

BACKOFF_INTERVAL = 5
private_constant :BACKOFF_INTERVAL

# @private
CONTEXT_KEY = :__rage_deferred_execution_context

# @private
RETRY_IN_CACHE_KEY = :__rage_deferred_retry_in
private_constant :RETRY_IN_CACHE_KEY

def perform
end

Expand Down Expand Up @@ -175,23 +176,34 @@ def enqueue(*args, delay: nil, delay_until: nil, **kwargs)

# @private
def __next_retry_in(attempts, exception)
cached = Fiber[RETRY_IN_CACHE_KEY]
if cached && cached[0] == attempts
return cached[1]
end

max = @__max_retries || MAX_ATTEMPTS
return if attempts > max
return __cache_retry_in(attempts, nil) if attempts > max

interval = retry_interval(exception, attempt: attempts)
return if !interval
return __cache_retry_in(attempts, nil) if !interval

unless interval.is_a?(Numeric)
Rage.logger.warn("#{name}.retry_interval returned #{interval.class}, expected Numeric, false, or nil; falling back to default backoff")
return __default_backoff(attempts)
return __cache_retry_in(attempts, __default_backoff(attempts))
end

interval
__cache_retry_in(attempts, interval)
end

# @private
def __cache_retry_in(attempts, value)
Fiber[RETRY_IN_CACHE_KEY] = [attempts, value]
value
end

# @private
def __default_backoff(attempt)
rand(BACKOFF_INTERVAL * 2**attempt) + 1
(attempt**4) + 10 + (rand(15) * attempt)
end
end
end
16 changes: 16 additions & 0 deletions spec/deferred/metadata_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,20 @@
expect(subject.will_retry?).to eq(false)
end
end

describe ".will_retry_in" do
before do
Rage::Deferred::Context.inc_attempts(context)
end

it "returns the retry interval when retries remain" do
expect(task).to receive(:__next_retry_in).with(2, nil).and_return(15)
expect(subject.will_retry_in).to eq(15)
end

it "returns nil when no retries remain" do
expect(task).to receive(:__next_retry_in).with(2, nil).and_return(nil)
expect(subject.will_retry_in).to be_nil
end
end
end
53 changes: 40 additions & 13 deletions spec/deferred/task_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ def perform(arg, kwarg:)
stub_const("MyTask", task_class)
end

after do
Fiber[:__rage_deferred_retry_in] = nil
end

describe ".enqueue" do
let(:queue) { instance_double(Rage::Deferred::Queue, enqueue: true) }
let(:context) { { class: "MyTask" } }
Expand Down Expand Up @@ -44,17 +48,40 @@ def perform(arg, kwarg:)
end

describe ".__next_retry_in" do
it "returns the next retry interval with exponential backoff" do
expect(task_class.__next_retry_in(0, nil)).to be_between(1, 5)
expect(task_class.__next_retry_in(1, nil)).to be_between(1, 10)
expect(task_class.__next_retry_in(2, nil)).to be_between(1, 20)
expect(task_class.__next_retry_in(3, nil)).to be_between(1, 40)
expect(task_class.__next_retry_in(4, nil)).to be_between(1, 80)
it "returns the next retry interval with quartic backoff" do
# Formula: (attempt**4) + 10 + (rand(15) * attempt)
# rand(15) => 0..14

# attempt 0 -> 0^4 + 10 + (0..14)*0 = 10
expect(task_class.__next_retry_in(0, nil)).to eq(10)
# attempt 1 -> 1^4 + 10 + (0..14)*1 = 11..25
expect(task_class.__next_retry_in(1, nil)).to be_between(11, 25)
# attempt 2 -> 2^4 + 10 + (0..14)*2 = 26..54
expect(task_class.__next_retry_in(2, nil)).to be_between(26, 54)
# attempt 3 -> 3^4 + 10 + (0..14)*3 = 91..133
expect(task_class.__next_retry_in(3, nil)).to be_between(91, 133)
# attempt 4 -> 4^4 + 10 + (0..14)*4 = 266..322
expect(task_class.__next_retry_in(4, nil)).to be_between(266, 322)
end

it "returns nil when attempts exceed max" do
expect(task_class.__next_retry_in(5, nil)).to be_between(1, 160)
expect(task_class.__next_retry_in(6, nil)).to be_nil
# With MAX_ATTEMPTS=20 and current guard (attempts > max),
# attempt 20 still retries, attempt 21 stops.
expect(task_class.__next_retry_in(20, nil)).to be_a(Numeric)
expect(task_class.__next_retry_in(21, nil)).to be_nil
end

it "returns the same value on repeated calls with the same attempts" do
first = task_class.__next_retry_in(2, nil)
second = task_class.__next_retry_in(2, nil)
expect(first).to eq(second)
end

it "returns different values for different attempts" do
val_at_0 = task_class.__next_retry_in(0, nil)
val_at_4 = task_class.__next_retry_in(4, nil)
expect(val_at_0).to be_a(Numeric)
expect(val_at_4).to be_a(Numeric)
end
end

Expand Down Expand Up @@ -114,7 +141,7 @@ def perform(arg, kwarg:)
it "returns an interval for any attempt" do
interval = task_class.retry_interval(StandardError.new, attempt: 1)
expect(interval).to be_a(Integer)
expect(interval).to be >= 1
expect(interval).to be_between(11, 25)
end

it "always returns a backoff (max check is in __next_retry_in)" do
Expand Down Expand Up @@ -154,7 +181,7 @@ def perform(arg, kwarg:)
it "falls back to default for unmatched exception" do
interval = task_class.retry_interval(StandardError.new, attempt: 1)
expect(interval).to be_a(Integer)
expect(interval).to be >= 1
expect(interval).to be_between(11, 25)
end

it "__next_retry_in returns interval for retryable" do
Expand All @@ -167,7 +194,7 @@ def perform(arg, kwarg:)

it "__next_retry_in uses default backoff for unmatched" do
interval = task_class.__next_retry_in(1, StandardError.new)
expect(interval).to be_between(1, 10)
expect(interval).to be_between(11, 25)
end

it "__next_retry_in enforces max_retries even with custom interval" do
Expand Down Expand Up @@ -216,15 +243,15 @@ def perform(arg, kwarg:)
task_class.define_singleton_method(:retry_interval) { |_exception, attempt:| "invalid" }
result = task_class.__next_retry_in(1, StandardError.new)
expect(result).to be_a(Numeric)
expect(result).to be >= 1
expect(result).to be_between(11, 25)
expect(logger).to have_received(:warn).with(/returned String, expected Numeric/)
end

it "logs a warning and falls back to default backoff for Array" do
task_class.define_singleton_method(:retry_interval) { |_exception, attempt:| [10] }
result = task_class.__next_retry_in(1, StandardError.new)
expect(result).to be_a(Numeric)
expect(result).to be >= 1
expect(result).to be_between(11, 25)
expect(logger).to have_received(:warn).with(/returned Array, expected Numeric/)
end
end
Expand Down
Loading