Skip to content

Commit 41b8f7c

Browse files
committed
[Deferred] Add configurable retry options with max_retries and retry_interval
Add two class-level APIs for customizing retry behavior in deferred tasks: - max_retries: sets the maximum number of retries (max_retries 3 means the task will be executed up to 4 times total) - retry_interval: an overridable class method that receives the exception and attempt number, returning an interval in seconds (Numeric), or false/nil to abort retries Consolidate retry logic into __next_retry_in as the single source of truth for both whether and when to retry, removing the separate __should_retry? method. Validate retry_interval return values: accept any Numeric (Integer or Float), treat false/nil as abort, and log a warning for unexpected types while falling back to the default exponential backoff. Update __perform to return the exception object on failure instead of false, enabling retry_interval to make decisions based on exception type. Add comprehensive specs covering custom retry intervals, exception-based routing, and edge cases (Float, nil, false, 0, negative, String, Array).
1 parent d82f35a commit 41b8f7c

File tree

7 files changed

+232
-34
lines changed

7 files changed

+232
-34
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
### Added
44

5+
- [Deferred] Support configurable retry options with `max_retries` and `retry_interval` by [@Digvijay](https://github.com/Digvijay-x1) (#215).
56
- [Cable] Add support for `stop_stream_from` and `stop_stream_for` by [@Digvijay](https://github.com/Digvijay-x1) (#217).
67

78
## [1.21.1] - 2026-02-27

lib/rage/deferred/metadata.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def retrying?
2727
# @return [Boolean] `true` if a failure will schedule another attempt, `false` otherwise
2828
def will_retry?
2929
task = Rage::Deferred::Context.get_task(context)
30-
task.__should_retry?(attempts)
30+
!!task.__next_retry_in(attempts, nil)
3131
end
3232

3333
private

lib/rage/deferred/queue.rb

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,15 @@ def schedule(task_id, context, publish_in: nil)
3838
Fiber.schedule do
3939
Iodine.task_inc!
4040

41-
is_completed = task.new.__perform(context)
41+
result = task.new.__perform(context)
4242

43-
if is_completed
43+
if result == true
4444
@backend.remove(task_id)
4545
else
4646
attempts = Rage::Deferred::Context.inc_attempts(context)
47-
if task.__should_retry?(attempts)
48-
enqueue(context, delay: task.__next_retry_in(attempts), task_id:)
47+
retry_in = task.__next_retry_in(attempts, result)
48+
if retry_in
49+
enqueue(context, delay: retry_in, task_id:)
4950
else
5051
@backend.remove(task_id)
5152
end

lib/rage/deferred/task.rb

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ def __perform(context)
8585
Rage.logger.error("Deferred task failed with exception: #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
8686
end
8787
end
88-
false
88+
e
8989
end
9090

9191
private def restore_log_info(context)
@@ -105,6 +105,56 @@ def self.included(klass)
105105
end
106106

107107
module ClassMethods
108+
# Set the maximum number of retry attempts for this task.
109+
#
110+
# @param count [Integer] the maximum number of retry attempts
111+
# @example
112+
# class SendWelcomeEmail
113+
# include Rage::Deferred::Task
114+
# max_retries 10
115+
#
116+
# def perform(email)
117+
# # ...
118+
# end
119+
# end
120+
def max_retries(count)
121+
@__max_retries = count
122+
end
123+
124+
# Override this method to customize retry behavior per exception.
125+
#
126+
# Return an Integer to retry in that many seconds.
127+
# Return `super` to use the default exponential backoff.
128+
# Return `false` or `nil` to abort retries.
129+
#
130+
# @param exception [Exception] the exception that caused the failure
131+
# @param attempt [Integer] the current attempt number (1-indexed)
132+
# @return [Integer, false, nil] the retry interval in seconds, or false/nil to abort
133+
# @example
134+
# class ProcessPayment
135+
# include Rage::Deferred::Task
136+
#
137+
# def self.retry_interval(exception, attempt:)
138+
# case exception
139+
# when TemporaryNetworkError
140+
# 10 # Retry in 10 seconds
141+
# when InvalidDataError
142+
# false # Do not retry
143+
# else
144+
# super # Default backoff strategy
145+
# end
146+
# end
147+
#
148+
# def perform(payment_id)
149+
# # ...
150+
# end
151+
# end
152+
def retry_interval(exception, attempt:)
153+
max = @__max_retries || MAX_ATTEMPTS
154+
return false if attempt > max
155+
rand(BACKOFF_INTERVAL * 2**attempt) + 1
156+
end
157+
108158
def enqueue(*args, delay: nil, delay_until: nil, **kwargs)
109159
context = Rage::Deferred::Context.build(self, args, kwargs)
110160

@@ -118,13 +168,16 @@ def enqueue(*args, delay: nil, delay_until: nil, **kwargs)
118168
end
119169

120170
# @private
121-
def __should_retry?(attempts)
122-
attempts < MAX_ATTEMPTS
123-
end
171+
def __next_retry_in(attempts, exception)
172+
interval = retry_interval(exception, attempt: attempts)
173+
return if !interval
124174

125-
# @private
126-
def __next_retry_in(attempts)
127-
rand(BACKOFF_INTERVAL * 2**attempts.to_i) + 1
175+
unless interval.is_a?(Numeric)
176+
Rage.logger.warn("#{name}.retry_interval returned #{interval.class}, expected Numeric, false, or nil; falling back to default backoff")
177+
return rand(BACKOFF_INTERVAL * 2**attempts.to_i) + 1
178+
end
179+
180+
interval
128181
end
129182
end
130183
end

spec/deferred/metadata_spec.rb

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,14 @@
6969
Rage::Deferred::Context.inc_attempts(context)
7070
end
7171

72-
it "delegates to Task.__should_retry?" do
73-
expect(task).to receive(:__should_retry?).with(2).and_return(:should_retry_result)
74-
expect(subject.will_retry?).to eq(:should_retry_result)
72+
it "delegates to Task.__next_retry_in" do
73+
expect(task).to receive(:__next_retry_in).with(2, nil).and_return(10)
74+
expect(subject.will_retry?).to eq(true)
75+
end
76+
77+
it "returns false when __next_retry_in returns nil" do
78+
expect(task).to receive(:__next_retry_in).with(2, nil).and_return(nil)
79+
expect(subject.will_retry?).to eq(false)
7580
end
7681
end
7782
end

spec/deferred/queue_spec.rb

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
RSpec.describe Rage::Deferred::Queue do
44
let(:backend) { double("backend", add: "task-id", remove: nil) }
5-
let(:task_class) { double("task_class", __should_retry?: false) }
5+
let(:task_class) { double("task_class") }
66
let(:task_instance) { double("task_instance", __perform: true) }
7+
78
let(:task_context) { "TestTask" }
89
let(:backpressure_config) { nil }
910

@@ -99,23 +100,24 @@
99100
end
100101

101102
context "when task fails" do
103+
let(:error) { StandardError.new("Something went wrong") }
104+
102105
before do
103-
allow(task_instance).to receive(:__perform).and_return(false)
106+
allow(task_instance).to receive(:__perform).and_return(error)
104107
allow(Rage::Deferred::Context).to receive(:inc_attempts).with(task_context).and_return(1)
105108
end
106109

107110
context "and should be retried" do
108111
it "re-enqueues the task with a delay" do
109-
allow(task_class).to receive(:__should_retry?).with(1).and_return(true)
110-
allow(task_class).to receive(:__next_retry_in).with(1).and_return(30)
112+
allow(task_class).to receive(:__next_retry_in).with(1, error).and_return(30)
111113
expect(subject).to receive(:enqueue).with(task_context, delay: 30, task_id: "task-id")
112114
subject.schedule("task-id", task_context)
113115
end
114116
end
115117

116118
context "and should not be retried" do
117119
it "removes the task from the backend" do
118-
allow(task_class).to receive(:__should_retry?).with(1).and_return(false)
120+
allow(task_class).to receive(:__next_retry_in).with(1, error).and_return(nil)
119121
expect(backend).to receive(:remove).with("task-id")
120122
subject.schedule("task-id", task_context)
121123
end

spec/deferred/task_spec.rb

Lines changed: 150 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -43,23 +43,159 @@ def perform(arg, kwarg:)
4343
end
4444
end
4545

46-
describe ".__should_retry?" do
47-
it "returns true if attempts are less than max" do
48-
expect(task_class.__should_retry?(4)).to be(true)
46+
describe ".__next_retry_in" do
47+
it "returns the next retry interval with exponential backoff" do
48+
expect(task_class.__next_retry_in(0, nil)).to be_between(1, 5)
49+
expect(task_class.__next_retry_in(1, nil)).to be_between(1, 10)
50+
expect(task_class.__next_retry_in(2, nil)).to be_between(1, 20)
51+
expect(task_class.__next_retry_in(3, nil)).to be_between(1, 40)
52+
expect(task_class.__next_retry_in(4, nil)).to be_between(1, 80)
4953
end
5054

51-
it "returns false if attempts are equal to max" do
52-
expect(task_class.__should_retry?(5)).to be(false)
55+
it "returns nil when attempts exceed max" do
56+
expect(task_class.__next_retry_in(5, nil)).to be_between(1, 160)
57+
expect(task_class.__next_retry_in(6, nil)).to be_nil
5358
end
5459
end
5560

56-
describe ".__next_retry_in" do
57-
it "returns the next retry interval with exponential backoff" do
58-
expect(task_class.__next_retry_in(0)).to be_between(1, 5)
59-
expect(task_class.__next_retry_in(1)).to be_between(1, 10)
60-
expect(task_class.__next_retry_in(2)).to be_between(1, 20)
61-
expect(task_class.__next_retry_in(3)).to be_between(1, 40)
62-
expect(task_class.__next_retry_in(4)).to be_between(1, 80)
61+
describe ".max_retries" do
62+
context "with custom max" do
63+
before { task_class.max_retries(3) }
64+
65+
it "retries up to custom max" do
66+
expect(task_class.__next_retry_in(3, StandardError.new)).to be_a(Numeric)
67+
end
68+
69+
it "stops after custom max" do
70+
expect(task_class.__next_retry_in(4, StandardError.new)).to be_nil
71+
end
72+
73+
it "means the task is executed up to 4 times total" do
74+
# attempt 1 = original, attempt 2-4 = retries
75+
expect(task_class.__next_retry_in(1, StandardError.new)).to be_a(Numeric)
76+
expect(task_class.__next_retry_in(2, StandardError.new)).to be_a(Numeric)
77+
expect(task_class.__next_retry_in(3, StandardError.new)).to be_a(Numeric)
78+
expect(task_class.__next_retry_in(4, StandardError.new)).to be_nil
79+
end
80+
end
81+
end
82+
83+
describe ".retry_interval" do
84+
context "default behavior (no override)" do
85+
it "returns an interval when under MAX_ATTEMPTS" do
86+
interval = task_class.retry_interval(StandardError.new, attempt: 1)
87+
expect(interval).to be_a(Integer)
88+
expect(interval).to be >= 1
89+
end
90+
91+
it "returns false when past MAX_ATTEMPTS" do
92+
expect(task_class.retry_interval(StandardError.new, attempt: 5)).to be_a(Integer)
93+
expect(task_class.retry_interval(StandardError.new, attempt: 6)).to be(false)
94+
end
95+
96+
it "respects max_retries setting" do
97+
task_class.max_retries(2)
98+
expect(task_class.retry_interval(StandardError.new, attempt: 1)).to be_a(Integer)
99+
expect(task_class.retry_interval(StandardError.new, attempt: 2)).to be_a(Integer)
100+
expect(task_class.retry_interval(StandardError.new, attempt: 3)).to be(false)
101+
end
102+
end
103+
104+
context "with override" do
105+
let(:temporary_error) { Class.new(StandardError) }
106+
let(:fatal_error) { Class.new(StandardError) }
107+
108+
before do
109+
tmp_err = temporary_error
110+
fat_err = fatal_error
111+
112+
task_class.define_singleton_method(:retry_interval) do |exception, attempt:|
113+
case exception
114+
when tmp_err
115+
10
116+
when fat_err
117+
false
118+
else
119+
super(exception, attempt: attempt)
120+
end
121+
end
122+
end
123+
124+
it "returns custom interval for matching exception" do
125+
expect(task_class.retry_interval(temporary_error.new, attempt: 1)).to eq(10)
126+
end
127+
128+
it "returns false for non-retryable exception" do
129+
expect(task_class.retry_interval(fatal_error.new, attempt: 1)).to be(false)
130+
end
131+
132+
it "falls back to default for unmatched exception" do
133+
interval = task_class.retry_interval(StandardError.new, attempt: 1)
134+
expect(interval).to be_a(Integer)
135+
expect(interval).to be >= 1
136+
end
137+
138+
it "__next_retry_in returns interval for retryable" do
139+
expect(task_class.__next_retry_in(1, temporary_error.new)).to eq(10)
140+
end
141+
142+
it "__next_retry_in returns nil for non-retryable" do
143+
expect(task_class.__next_retry_in(1, fatal_error.new)).to be_nil
144+
end
145+
146+
it "__next_retry_in uses default backoff for unmatched" do
147+
interval = task_class.__next_retry_in(1, StandardError.new)
148+
expect(interval).to be_between(1, 10)
149+
end
150+
end
151+
152+
context "with edge case return values" do
153+
let(:logger) { double(warn: nil) }
154+
155+
before do
156+
allow(Rage).to receive(:logger).and_return(logger)
157+
end
158+
159+
it "accepts a Float return value" do
160+
task_class.define_singleton_method(:retry_interval) { |_exception, attempt:| 2.5 }
161+
expect(task_class.__next_retry_in(1, StandardError.new)).to eq(2.5)
162+
end
163+
164+
it "returns nil when retry_interval returns nil" do
165+
task_class.define_singleton_method(:retry_interval) { |_exception, attempt:| nil }
166+
expect(task_class.__next_retry_in(1, StandardError.new)).to be_nil
167+
end
168+
169+
it "returns nil when retry_interval returns false" do
170+
task_class.define_singleton_method(:retry_interval) { |_exception, attempt:| false }
171+
expect(task_class.__next_retry_in(1, StandardError.new)).to be_nil
172+
end
173+
174+
it "accepts zero as a valid interval" do
175+
task_class.define_singleton_method(:retry_interval) { |_exception, attempt:| 0 }
176+
expect(task_class.__next_retry_in(1, StandardError.new)).to eq(0)
177+
end
178+
179+
it "accepts a negative number as a Numeric" do
180+
task_class.define_singleton_method(:retry_interval) { |_exception, attempt:| -5 }
181+
expect(task_class.__next_retry_in(1, StandardError.new)).to eq(-5)
182+
end
183+
184+
it "logs a warning and falls back to default backoff for String" do
185+
task_class.define_singleton_method(:retry_interval) { |_exception, attempt:| "invalid" }
186+
result = task_class.__next_retry_in(1, StandardError.new)
187+
expect(result).to be_a(Numeric)
188+
expect(result).to be >= 1
189+
expect(logger).to have_received(:warn).with(/returned String, expected Numeric/)
190+
end
191+
192+
it "logs a warning and falls back to default backoff for Array" do
193+
task_class.define_singleton_method(:retry_interval) { |_exception, attempt:| [10] }
194+
result = task_class.__next_retry_in(1, StandardError.new)
195+
expect(result).to be_a(Numeric)
196+
expect(result).to be >= 1
197+
expect(logger).to have_received(:warn).with(/returned Array, expected Numeric/)
198+
end
63199
end
64200
end
65201

@@ -145,8 +281,8 @@ def perform(arg, kwarg:)
145281
expect(logger).to have_received(:error).with("Deferred task failed with exception: StandardError (Something went wrong):\nline 1\nline 2")
146282
end
147283

148-
it "returns false" do
149-
expect(task.__perform(context)).to be(false)
284+
it "returns the exception" do
285+
expect(task.__perform(context)).to be(error)
150286
end
151287

152288
context "with suppressed exception logging" do

0 commit comments

Comments
 (0)