Skip to content

Commit 18ec7c8

Browse files
authored
feat: add linear retry strategy (#468)
* feat: add linear retry strategy --------- Co-authored-by: Nanook <nanookclaw@users.noreply.github.com>
1 parent e5c8860 commit 18ec7c8

2 files changed

Lines changed: 330 additions & 1 deletion

File tree

sdk/src/main/java/software/amazon/lambda/durable/retry/RetryStrategies.java

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* Factory class for creating common retry strategies.
1010
*
1111
* <p>This class provides preset retry strategies for common use cases, as well as factory methods for creating custom
12-
* retry strategies with exponential backoff and jitter.
12+
* retry strategies with exponential or linear backoff and jitter.
1313
*/
1414
public class RetryStrategies {
1515

@@ -28,6 +28,18 @@ public static class Presets {
2828
JitterStrategy.FULL // jitter
2929
);
3030

31+
/**
32+
* Linear retry strategy: - 6 total attempts (1 initial + 5 retries) - Initial delay: 1 second - Max delay: 5
33+
* seconds - Increment: 1 second - Jitter: NONE
34+
*/
35+
public static final RetryStrategy LINEAR = linearBackoff(
36+
6, // maxAttempts
37+
Duration.ofSeconds(1), // initialDelay
38+
Duration.ofSeconds(5), // maxDelay
39+
Duration.ofSeconds(1), // increment
40+
JitterStrategy.NONE // jitter
41+
);
42+
3143
/** No retry strategy - fails immediately on first error. Use this for operations that should not be retried. */
3244
public static final RetryStrategy NO_RETRY = (error, attempt) -> RetryDecision.fail();
3345
}
@@ -79,6 +91,89 @@ public static RetryStrategy exponentialBackoff(
7991
};
8092
}
8193

94+
/**
95+
* Creates a linear backoff retry strategy.
96+
*
97+
* <p>The delay calculation follows the formula: delay = initialDelay + increment × (attempt-1)
98+
*
99+
* @param maxAttempts Maximum number of attempts (including initial attempt)
100+
* @param initialDelay Initial delay before first retry
101+
* @param increment Amount to add to the delay after each retry attempt
102+
* @return RetryStrategy implementing linear backoff
103+
*/
104+
public static RetryStrategy linearBackoff(int maxAttempts, Duration initialDelay, Duration increment) {
105+
if (maxAttempts <= 0) {
106+
throw new IllegalArgumentException("maxAttempts must be positive");
107+
}
108+
ParameterValidator.validateDuration(initialDelay, "initialDelay");
109+
ParameterValidator.validateDuration(increment, "increment");
110+
111+
return (error, attempt) -> {
112+
if (attempt >= maxAttempts) {
113+
return RetryDecision.fail();
114+
}
115+
116+
var delay = initialDelay.plus(increment.multipliedBy(attempt - 1));
117+
return RetryDecision.retry(delay);
118+
};
119+
}
120+
121+
/**
122+
* Creates a linear backoff retry strategy.
123+
*
124+
* <p>The delay calculation follows the formula: baseDelay = min(initialDelay + increment × (attempt-1), maxDelay)
125+
*
126+
* @param maxAttempts Maximum number of attempts (including initial attempt)
127+
* @param initialDelay Initial delay before first retry
128+
* @param maxDelay Maximum delay between retries
129+
* @param increment Amount to add to the delay after each retry attempt
130+
* @param jitter Jitter strategy to apply to delays
131+
* @return RetryStrategy implementing linear backoff with jitter
132+
*/
133+
public static RetryStrategy linearBackoff(
134+
int maxAttempts, Duration initialDelay, Duration maxDelay, Duration increment, JitterStrategy jitter) {
135+
if (maxAttempts <= 0) {
136+
throw new IllegalArgumentException("maxAttempts must be positive");
137+
}
138+
ParameterValidator.validateDuration(initialDelay, "initialDelay");
139+
ParameterValidator.validateDuration(maxDelay, "maxDelay");
140+
ParameterValidator.validateDuration(increment, "increment");
141+
if (jitter == null) {
142+
throw new IllegalArgumentException("jitter cannot be null");
143+
}
144+
145+
return (error, attempt) -> {
146+
if (attempt >= maxAttempts) {
147+
return RetryDecision.fail();
148+
}
149+
150+
var baseDelay = calculateCappedLinearDelay(initialDelay, maxDelay, increment, attempt);
151+
if (jitter == JitterStrategy.NONE) {
152+
return RetryDecision.retry(baseDelay);
153+
}
154+
155+
var delayWithJitter = jitter.apply(baseDelay.toSeconds());
156+
var finalDelaySeconds = Math.max(1, Math.round(delayWithJitter));
157+
158+
return RetryDecision.retry(Duration.ofSeconds(finalDelaySeconds));
159+
};
160+
}
161+
162+
private static Duration calculateCappedLinearDelay(
163+
Duration initialDelay, Duration maxDelay, Duration increment, int attempt) {
164+
if (initialDelay.compareTo(maxDelay) >= 0) {
165+
return maxDelay;
166+
}
167+
168+
var increments = attempt - 1L;
169+
var remaining = maxDelay.minus(initialDelay);
170+
if (increments > remaining.dividedBy(increment)) {
171+
return maxDelay;
172+
}
173+
174+
return initialDelay.plus(increment.multipliedBy(increments));
175+
}
176+
82177
/**
83178
* Creates a simple retry strategy that retries a fixed number of times with a fixed delay.
84179
*

sdk/src/test/java/software/amazon/lambda/durable/retry/RetryStrategiesTest.java

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,240 @@ void testFixedDelayStrategy() {
138138
assertFalse(decision3.shouldRetry());
139139
}
140140

141+
@Test
142+
void linearBackoff_withCustomDelays_shouldIncreaseByIncrement() {
143+
var strategy = RetryStrategies.linearBackoff(5, Duration.ofSeconds(2), Duration.ofSeconds(3));
144+
145+
var decision1 = strategy.makeRetryDecision(new RuntimeException("test"), 1);
146+
var decision2 = strategy.makeRetryDecision(new RuntimeException("test"), 2);
147+
var decision3 = strategy.makeRetryDecision(new RuntimeException("test"), 3);
148+
var decision4 = strategy.makeRetryDecision(new RuntimeException("test"), 4);
149+
150+
assertTrue(decision1.shouldRetry());
151+
assertEquals(Duration.ofSeconds(2), decision1.delay());
152+
153+
assertTrue(decision2.shouldRetry());
154+
assertEquals(Duration.ofSeconds(5), decision2.delay());
155+
156+
assertTrue(decision3.shouldRetry());
157+
assertEquals(Duration.ofSeconds(8), decision3.delay());
158+
159+
assertTrue(decision4.shouldRetry());
160+
assertEquals(Duration.ofSeconds(11), decision4.delay());
161+
}
162+
163+
@Test
164+
void linearBackoff_withOldOverload_shouldRemainUncappedAndDeterministic() {
165+
var strategy = RetryStrategies.linearBackoff(5, Duration.ofSeconds(10), Duration.ofSeconds(10));
166+
167+
assertEquals(
168+
Duration.ofSeconds(10),
169+
strategy.makeRetryDecision(new RuntimeException("test"), 1).delay());
170+
assertEquals(
171+
Duration.ofSeconds(20),
172+
strategy.makeRetryDecision(new RuntimeException("test"), 2).delay());
173+
assertEquals(
174+
Duration.ofSeconds(30),
175+
strategy.makeRetryDecision(new RuntimeException("test"), 3).delay());
176+
assertEquals(
177+
Duration.ofSeconds(40),
178+
strategy.makeRetryDecision(new RuntimeException("test"), 4).delay());
179+
}
180+
181+
@Test
182+
void linearBackoff_withMaxDelay_shouldCapDelay() {
183+
var strategy = RetryStrategies.linearBackoff(
184+
6, Duration.ofSeconds(2), Duration.ofSeconds(7), Duration.ofSeconds(3), JitterStrategy.NONE);
185+
186+
assertEquals(
187+
Duration.ofSeconds(2),
188+
strategy.makeRetryDecision(new RuntimeException("test"), 1).delay());
189+
assertEquals(
190+
Duration.ofSeconds(5),
191+
strategy.makeRetryDecision(new RuntimeException("test"), 2).delay());
192+
assertEquals(
193+
Duration.ofSeconds(7),
194+
strategy.makeRetryDecision(new RuntimeException("test"), 3).delay());
195+
assertEquals(
196+
Duration.ofSeconds(7),
197+
strategy.makeRetryDecision(new RuntimeException("test"), 4).delay());
198+
}
199+
200+
@Test
201+
void linearBackoff_withMaxDelay_shouldCapBeforeOverflow() {
202+
var strategy = RetryStrategies.linearBackoff(
203+
Integer.MAX_VALUE,
204+
Duration.ofSeconds(1),
205+
Duration.ofSeconds(5),
206+
Duration.ofSeconds(Long.MAX_VALUE),
207+
JitterStrategy.NONE);
208+
209+
var decision = assertDoesNotThrow(() -> strategy.makeRetryDecision(new RuntimeException("test"), 2));
210+
211+
assertTrue(decision.shouldRetry());
212+
assertEquals(Duration.ofSeconds(5), decision.delay());
213+
}
214+
215+
@Test
216+
void linearBackoff_withNewOverload_shouldStopAtMaxAttempts() {
217+
var strategy = RetryStrategies.linearBackoff(
218+
3, Duration.ofSeconds(1), Duration.ofSeconds(5), Duration.ofSeconds(1), JitterStrategy.NONE);
219+
220+
var decision1 = strategy.makeRetryDecision(new RuntimeException("test"), 1);
221+
var decision2 = strategy.makeRetryDecision(new RuntimeException("test"), 2);
222+
var decision3 = strategy.makeRetryDecision(new RuntimeException("test"), 3);
223+
224+
assertTrue(decision1.shouldRetry());
225+
assertEquals(Duration.ofSeconds(1), decision1.delay());
226+
assertTrue(decision2.shouldRetry());
227+
assertEquals(Duration.ofSeconds(2), decision2.delay());
228+
assertFalse(decision3.shouldRetry());
229+
}
230+
231+
@Test
232+
void linearBackoff_withMaxDelayLessThanInitialDelay_shouldCapFirstRetry() {
233+
var strategy = RetryStrategies.linearBackoff(
234+
4, Duration.ofSeconds(10), Duration.ofSeconds(5), Duration.ofSeconds(3), JitterStrategy.NONE);
235+
236+
assertEquals(
237+
Duration.ofSeconds(5),
238+
strategy.makeRetryDecision(new RuntimeException("test"), 1).delay());
239+
assertEquals(
240+
Duration.ofSeconds(5),
241+
strategy.makeRetryDecision(new RuntimeException("test"), 2).delay());
242+
}
243+
244+
@Test
245+
void linearBackoff_withJitter_shouldProduceDelayInExpectedRange() {
246+
var fullStrategy = RetryStrategies.linearBackoff(
247+
5, Duration.ofSeconds(10), Duration.ofSeconds(15), Duration.ofSeconds(10), JitterStrategy.FULL);
248+
var halfStrategy = RetryStrategies.linearBackoff(
249+
5, Duration.ofSeconds(10), Duration.ofSeconds(15), Duration.ofSeconds(10), JitterStrategy.HALF);
250+
251+
for (int i = 0; i < 10; i++) {
252+
var fullDelay = fullStrategy
253+
.makeRetryDecision(new RuntimeException("test"), 2)
254+
.delay()
255+
.toSeconds();
256+
assertTrue(fullDelay >= 1 && fullDelay <= 15);
257+
258+
var halfDelay = halfStrategy
259+
.makeRetryDecision(new RuntimeException("test"), 2)
260+
.delay()
261+
.toSeconds();
262+
assertTrue(halfDelay >= 8 && halfDelay <= 15);
263+
}
264+
}
265+
266+
@Test
267+
void linearPreset_shouldUseOneThroughFiveSecondDelays() {
268+
var strategy = RetryStrategies.Presets.LINEAR;
269+
270+
for (int attempt = 1; attempt <= 5; attempt++) {
271+
var decision = strategy.makeRetryDecision(new RuntimeException("test"), attempt);
272+
273+
assertTrue(decision.shouldRetry(), "Should retry on attempt " + attempt);
274+
assertEquals(Duration.ofSeconds(attempt), decision.delay());
275+
}
276+
277+
var finalDecision = strategy.makeRetryDecision(new RuntimeException("test"), 6);
278+
assertFalse(finalDecision.shouldRetry());
279+
}
280+
281+
@Test
282+
void linearBackoff_shouldStopAtMaxAttempts() {
283+
var strategy = RetryStrategies.linearBackoff(3, Duration.ofSeconds(1), Duration.ofSeconds(1));
284+
285+
var decision1 = strategy.makeRetryDecision(new RuntimeException("test"), 1);
286+
var decision2 = strategy.makeRetryDecision(new RuntimeException("test"), 2);
287+
var decision3 = strategy.makeRetryDecision(new RuntimeException("test"), 3);
288+
289+
assertTrue(decision1.shouldRetry());
290+
assertTrue(decision2.shouldRetry());
291+
assertFalse(decision3.shouldRetry());
292+
}
293+
294+
@Test
295+
void linearBackoff_withInvalidMaxAttempts_shouldThrow() {
296+
var exception = assertThrows(
297+
IllegalArgumentException.class,
298+
() -> RetryStrategies.linearBackoff(0, Duration.ofSeconds(1), Duration.ofSeconds(1)));
299+
300+
assertTrue(exception.getMessage().contains("maxAttempts"));
301+
assertTrue(exception.getMessage().contains("positive"));
302+
}
303+
304+
@Test
305+
void linearBackoff_withSubSecondInitialDelay_shouldThrow() {
306+
var exception = assertThrows(
307+
IllegalArgumentException.class,
308+
() -> RetryStrategies.linearBackoff(3, Duration.ofMillis(500), Duration.ofSeconds(1)));
309+
310+
assertTrue(exception.getMessage().contains("initialDelay"));
311+
assertTrue(exception.getMessage().contains("at least 1 second"));
312+
}
313+
314+
@Test
315+
void linearBackoff_withNullInitialDelay_shouldThrow() {
316+
var exception = assertThrows(
317+
IllegalArgumentException.class, () -> RetryStrategies.linearBackoff(3, null, Duration.ofSeconds(1)));
318+
319+
assertTrue(exception.getMessage().contains("initialDelay"));
320+
assertTrue(exception.getMessage().contains("cannot be null"));
321+
}
322+
323+
@Test
324+
void linearBackoff_withSubSecondIncrement_shouldThrow() {
325+
var exception = assertThrows(
326+
IllegalArgumentException.class,
327+
() -> RetryStrategies.linearBackoff(3, Duration.ofSeconds(1), Duration.ofMillis(500)));
328+
329+
assertTrue(exception.getMessage().contains("increment"));
330+
assertTrue(exception.getMessage().contains("at least 1 second"));
331+
}
332+
333+
@Test
334+
void linearBackoff_withNullIncrement_shouldThrow() {
335+
var exception = assertThrows(
336+
IllegalArgumentException.class, () -> RetryStrategies.linearBackoff(3, Duration.ofSeconds(1), null));
337+
338+
assertTrue(exception.getMessage().contains("increment"));
339+
assertTrue(exception.getMessage().contains("cannot be null"));
340+
}
341+
342+
@Test
343+
void linearBackoff_withSubSecondMaxDelay_shouldThrow() {
344+
var exception = assertThrows(
345+
IllegalArgumentException.class,
346+
() -> RetryStrategies.linearBackoff(
347+
3, Duration.ofSeconds(1), Duration.ofMillis(500), Duration.ofSeconds(1), JitterStrategy.NONE));
348+
349+
assertTrue(exception.getMessage().contains("maxDelay"));
350+
assertTrue(exception.getMessage().contains("at least 1 second"));
351+
}
352+
353+
@Test
354+
void linearBackoff_withNullMaxDelay_shouldThrow() {
355+
var exception = assertThrows(
356+
IllegalArgumentException.class,
357+
() -> RetryStrategies.linearBackoff(
358+
3, Duration.ofSeconds(1), null, Duration.ofSeconds(1), JitterStrategy.NONE));
359+
360+
assertTrue(exception.getMessage().contains("maxDelay"));
361+
assertTrue(exception.getMessage().contains("cannot be null"));
362+
}
363+
364+
@Test
365+
void linearBackoff_withNullJitter_shouldThrow() {
366+
var exception = assertThrows(
367+
IllegalArgumentException.class,
368+
() -> RetryStrategies.linearBackoff(
369+
3, Duration.ofSeconds(1), Duration.ofSeconds(5), Duration.ofSeconds(1), null));
370+
371+
assertTrue(exception.getMessage().contains("jitter"));
372+
assertTrue(exception.getMessage().contains("cannot be null"));
373+
}
374+
141375
@Test
142376
void testInvalidParameters() {
143377
assertThrows(

0 commit comments

Comments
 (0)