Skip to content

Commit 52813a4

Browse files
committed
[Deferred] Add configurable retry options (#215)
Add retry_on class method to Rage::Deferred::Task allowing per-task configuration of retry behavior: - Custom retry count via attempts: (default: 5) - Retryable exception classes (default: all exceptions)
1 parent d82f35a commit 52813a4

File tree

5 files changed

+83
-12
lines changed

5 files changed

+83
-12
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 `retry_on` 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/queue.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,13 @@ 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)
47+
if task.__should_retry?(attempts, result)
4848
enqueue(context, delay: task.__next_retry_in(attempts), task_id:)
4949
else
5050
@backend.remove(task_id)

lib/rage/deferred/task.rb

Lines changed: 20 additions & 3 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,20 @@ def self.included(klass)
105105
end
106106

107107
module ClassMethods
108+
# Configure retry behavior for this task.
109+
#
110+
# @param exception_classes [Class] one or more exception classes to retry on.
111+
# If none are specified, the task will be retried on any exception.
112+
# @param attempts [Integer] the maximum number of retry attempts (default: 5)
113+
# @example Retry on specific exceptions with custom attempts
114+
# retry_on Net::OpenTimeout, Net::ReadTimeout, attempts: 10
115+
# @example Retry on all exceptions with custom attempts
116+
# retry_on attempts: 3
117+
def retry_on(*exception_classes, attempts: MAX_ATTEMPTS)
118+
@__retry_exceptions = exception_classes.empty? ? nil : exception_classes
119+
@__retry_max_attempts = attempts
120+
end
121+
108122
def enqueue(*args, delay: nil, delay_until: nil, **kwargs)
109123
context = Rage::Deferred::Context.build(self, args, kwargs)
110124

@@ -118,8 +132,11 @@ def enqueue(*args, delay: nil, delay_until: nil, **kwargs)
118132
end
119133

120134
# @private
121-
def __should_retry?(attempts)
122-
attempts < MAX_ATTEMPTS
135+
def __should_retry?(attempts, exception = nil)
136+
return false if attempts >= (@__retry_max_attempts || MAX_ATTEMPTS)
137+
return true if @__retry_exceptions.nil?
138+
139+
@__retry_exceptions.any? { |klass| exception.is_a?(klass) }
123140
end
124141

125142
# @private

spec/deferred/queue_spec.rb

Lines changed: 7 additions & 4 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,14 +100,16 @@
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)
112+
allow(task_class).to receive(:__should_retry?).with(1, error).and_return(true)
110113
allow(task_class).to receive(:__next_retry_in).with(1).and_return(30)
111114
expect(subject).to receive(:enqueue).with(task_context, delay: 30, task_id: "task-id")
112115
subject.schedule("task-id", task_context)
@@ -115,7 +118,7 @@
115118

116119
context "and should not be retried" do
117120
it "removes the task from the backend" do
118-
allow(task_class).to receive(:__should_retry?).with(1).and_return(false)
121+
allow(task_class).to receive(:__should_retry?).with(1, error).and_return(false)
119122
expect(backend).to receive(:remove).with("task-id")
120123
subject.schedule("task-id", task_context)
121124
end

spec/deferred/task_spec.rb

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,56 @@ def perform(arg, kwarg:)
6363
end
6464
end
6565

66+
describe ".retry_on" do
67+
context "with specific exceptions" do
68+
let(:retryable_error_a) { Class.new(StandardError) }
69+
let(:retryable_error_b) { Class.new(StandardError) }
70+
71+
before { task_class.retry_on(retryable_error_a, retryable_error_b, attempts: 3) }
72+
73+
it "retries on matching exception" do
74+
expect(task_class.__should_retry?(1, retryable_error_a.new)).to be(true)
75+
end
76+
77+
it "retries on subclass of matching exception" do
78+
expect(task_class.__should_retry?(1, retryable_error_b.new)).to be(true)
79+
end
80+
81+
it "does not retry on non-matching exception" do
82+
expect(task_class.__should_retry?(1, StandardError.new)).to be(false)
83+
end
84+
85+
it "respects custom attempts" do
86+
expect(task_class.__should_retry?(2, retryable_error_a.new)).to be(true)
87+
expect(task_class.__should_retry?(3, retryable_error_a.new)).to be(false)
88+
end
89+
end
90+
91+
context "with attempts only" do
92+
before { task_class.retry_on(attempts: 2) }
93+
94+
it "retries on any exception" do
95+
expect(task_class.__should_retry?(1, StandardError.new)).to be(true)
96+
end
97+
98+
it "stops after max attempts" do
99+
expect(task_class.__should_retry?(2, StandardError.new)).to be(false)
100+
end
101+
end
102+
103+
context "without retry_on (default behavior)" do
104+
it "retries up to 5 times on any exception" do
105+
expect(task_class.__should_retry?(4, RuntimeError.new)).to be(true)
106+
expect(task_class.__should_retry?(5, RuntimeError.new)).to be(false)
107+
end
108+
109+
it "retries without exception argument" do
110+
expect(task_class.__should_retry?(4)).to be(true)
111+
expect(task_class.__should_retry?(5)).to be(false)
112+
end
113+
end
114+
end
115+
66116
describe "#__perform" do
67117
let(:task) { task_class.new }
68118
let(:context) { double }
@@ -145,8 +195,8 @@ def perform(arg, kwarg:)
145195
expect(logger).to have_received(:error).with("Deferred task failed with exception: StandardError (Something went wrong):\nline 1\nline 2")
146196
end
147197

148-
it "returns false" do
149-
expect(task.__perform(context)).to be(false)
198+
it "returns the exception" do
199+
expect(task.__perform(context)).to be(error)
150200
end
151201

152202
context "with suppressed exception logging" do

0 commit comments

Comments
 (0)