Skip to content

Commit fb73e27

Browse files
committed
feat: add configurable linear retry jitter
1 parent 664b6fd commit fb73e27

2 files changed

Lines changed: 204 additions & 3 deletions

File tree

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

Lines changed: 68 additions & 3 deletions
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,8 +28,17 @@ public static class Presets {
2828
JitterStrategy.FULL // jitter
2929
);
3030

31-
/** Linear retry strategy: 6 total attempts (1 initial + 5 retries) with 1s, 2s, 3s, 4s, and 5s delays. */
32-
public static final RetryStrategy LINEAR = linearBackoff(6, Duration.ofSeconds(1), Duration.ofSeconds(1));
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+
);
3342

3443
/** No retry strategy - fails immediately on first error. Use this for operations that should not be retried. */
3544
public static final RetryStrategy NO_RETRY = (error, attempt) -> RetryDecision.fail();
@@ -109,6 +118,62 @@ public static RetryStrategy linearBackoff(int maxAttempts, Duration initialDelay
109118
};
110119
}
111120

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+
112177
/**
113178
* Creates a simple retry strategy that retries a fixed number of times with a fixed delay.
114179
*

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

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,109 @@ void linearBackoff_withCustomDelays_shouldIncreaseByIncrement() {
160160
assertEquals(Duration.ofSeconds(11), decision4.delay());
161161
}
162162

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+
163266
@Test
164267
void linearPreset_shouldUseOneThroughFiveSecondDelays() {
165268
var strategy = RetryStrategies.Presets.LINEAR;
@@ -236,6 +339,39 @@ void linearBackoff_withNullIncrement_shouldThrow() {
236339
assertTrue(exception.getMessage().contains("cannot be null"));
237340
}
238341

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+
239375
@Test
240376
void testInvalidParameters() {
241377
assertThrows(

0 commit comments

Comments
 (0)