@@ -43,23 +43,175 @@ 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+
82+ context "input validation" do
83+ it "converts string to integer" do
84+ task_class . max_retries ( "3" )
85+ expect ( task_class . __next_retry_in ( 3 , StandardError . new ) ) . to be_a ( Numeric )
86+ expect ( task_class . __next_retry_in ( 4 , StandardError . new ) ) . to be_nil
87+ end
88+
89+ it "converts float to integer" do
90+ task_class . max_retries ( 2.9 )
91+ expect ( task_class . __next_retry_in ( 2 , StandardError . new ) ) . to be_a ( Numeric )
92+ expect ( task_class . __next_retry_in ( 3 , StandardError . new ) ) . to be_nil
93+ end
94+ end
95+ end
96+
97+ describe ".retry_interval" do
98+ context "default behavior (no override)" do
99+ it "returns an interval for any attempt" do
100+ interval = task_class . retry_interval ( StandardError . new , attempt : 1 )
101+ expect ( interval ) . to be_a ( Integer )
102+ expect ( interval ) . to be >= 1
103+ end
104+
105+ it "always returns a backoff (max check is in __next_retry_in)" do
106+ expect ( task_class . retry_interval ( StandardError . new , attempt : 5 ) ) . to be_a ( Integer )
107+ expect ( task_class . retry_interval ( StandardError . new , attempt : 6 ) ) . to be_a ( Integer )
108+ end
109+ end
110+
111+ context "with override" do
112+ let ( :temporary_error ) { Class . new ( StandardError ) }
113+ let ( :fatal_error ) { Class . new ( StandardError ) }
114+
115+ before do
116+ tmp_err = temporary_error
117+ fat_err = fatal_error
118+
119+ task_class . define_singleton_method ( :retry_interval ) do |exception , attempt :|
120+ case exception
121+ when tmp_err
122+ 10
123+ when fat_err
124+ false
125+ else
126+ super ( exception , attempt : attempt )
127+ end
128+ end
129+ end
130+
131+ it "returns custom interval for matching exception" do
132+ expect ( task_class . retry_interval ( temporary_error . new , attempt : 1 ) ) . to eq ( 10 )
133+ end
134+
135+ it "returns false for non-retryable exception" do
136+ expect ( task_class . retry_interval ( fatal_error . new , attempt : 1 ) ) . to be ( false )
137+ end
138+
139+ it "falls back to default for unmatched exception" do
140+ interval = task_class . retry_interval ( StandardError . new , attempt : 1 )
141+ expect ( interval ) . to be_a ( Integer )
142+ expect ( interval ) . to be >= 1
143+ end
144+
145+ it "__next_retry_in returns interval for retryable" do
146+ expect ( task_class . __next_retry_in ( 1 , temporary_error . new ) ) . to eq ( 10 )
147+ end
148+
149+ it "__next_retry_in returns nil for non-retryable" do
150+ expect ( task_class . __next_retry_in ( 1 , fatal_error . new ) ) . to be_nil
151+ end
152+
153+ it "__next_retry_in uses default backoff for unmatched" do
154+ interval = task_class . __next_retry_in ( 1 , StandardError . new )
155+ expect ( interval ) . to be_between ( 1 , 10 )
156+ end
157+
158+ it "__next_retry_in enforces max_retries even with custom interval" do
159+ task_class . max_retries ( 2 )
160+ # attempt 1 & 2 should retry with custom interval
161+ expect ( task_class . __next_retry_in ( 1 , temporary_error . new ) ) . to eq ( 10 )
162+ expect ( task_class . __next_retry_in ( 2 , temporary_error . new ) ) . to eq ( 10 )
163+ # attempt 3 should be capped by max_retries
164+ expect ( task_class . __next_retry_in ( 3 , temporary_error . new ) ) . to be_nil
165+ end
166+ end
167+
168+ context "with edge case return values" do
169+ let ( :logger ) { double ( warn : nil ) }
170+
171+ before do
172+ allow ( Rage ) . to receive ( :logger ) . and_return ( logger )
173+ end
174+
175+ it "accepts a Float return value" do
176+ task_class . define_singleton_method ( :retry_interval ) { |_exception , attempt :| 2.5 }
177+ expect ( task_class . __next_retry_in ( 1 , StandardError . new ) ) . to eq ( 2.5 )
178+ end
179+
180+ it "returns nil when retry_interval returns nil" do
181+ task_class . define_singleton_method ( :retry_interval ) { |_exception , attempt :| nil }
182+ expect ( task_class . __next_retry_in ( 1 , StandardError . new ) ) . to be_nil
183+ end
184+
185+ it "returns nil when retry_interval returns false" do
186+ task_class . define_singleton_method ( :retry_interval ) { |_exception , attempt :| false }
187+ expect ( task_class . __next_retry_in ( 1 , StandardError . new ) ) . to be_nil
188+ end
189+
190+ it "accepts zero as a valid interval" do
191+ task_class . define_singleton_method ( :retry_interval ) { |_exception , attempt :| 0 }
192+ expect ( task_class . __next_retry_in ( 1 , StandardError . new ) ) . to eq ( 0 )
193+ end
194+
195+ it "accepts a negative number as a Numeric" do
196+ task_class . define_singleton_method ( :retry_interval ) { |_exception , attempt :| -5 }
197+ expect ( task_class . __next_retry_in ( 1 , StandardError . new ) ) . to eq ( -5 )
198+ end
199+
200+ it "logs a warning and falls back to default backoff for String" do
201+ task_class . define_singleton_method ( :retry_interval ) { |_exception , attempt :| "invalid" }
202+ result = task_class . __next_retry_in ( 1 , StandardError . new )
203+ expect ( result ) . to be_a ( Numeric )
204+ expect ( result ) . to be >= 1
205+ expect ( logger ) . to have_received ( :warn ) . with ( /returned String, expected Numeric/ )
206+ end
207+
208+ it "logs a warning and falls back to default backoff for Array" do
209+ task_class . define_singleton_method ( :retry_interval ) { |_exception , attempt :| [ 10 ] }
210+ result = task_class . __next_retry_in ( 1 , StandardError . new )
211+ expect ( result ) . to be_a ( Numeric )
212+ expect ( result ) . to be >= 1
213+ expect ( logger ) . to have_received ( :warn ) . with ( /returned Array, expected Numeric/ )
214+ end
63215 end
64216 end
65217
@@ -145,8 +297,8 @@ def perform(arg, kwarg:)
145297 expect ( logger ) . to have_received ( :error ) . with ( "Deferred task failed with exception: StandardError (Something went wrong):\n line 1\n line 2" )
146298 end
147299
148- it "returns false " do
149- expect ( task . __perform ( context ) ) . to be ( false )
300+ it "returns the exception " do
301+ expect ( task . __perform ( context ) ) . to be ( error )
150302 end
151303
152304 context "with suppressed exception logging" do
0 commit comments