From 664b6fde7613169e7ccc00ee2b9157ae8c6f2de7 Mon Sep 17 00:00:00 2001 From: Nanook Claw Date: Fri, 19 Jun 2026 03:12:02 +0000 Subject: [PATCH 1/2] feat: add linear retry strategy --- .../lambda/durable/retry/RetryStrategies.java | 30 ++++++ .../durable/retry/RetryStrategiesTest.java | 98 +++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/sdk/src/main/java/software/amazon/lambda/durable/retry/RetryStrategies.java b/sdk/src/main/java/software/amazon/lambda/durable/retry/RetryStrategies.java index c1b0b069e..250030542 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/retry/RetryStrategies.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/retry/RetryStrategies.java @@ -28,6 +28,9 @@ public static class Presets { JitterStrategy.FULL // jitter ); + /** Linear retry strategy: 6 total attempts (1 initial + 5 retries) with 1s, 2s, 3s, 4s, and 5s delays. */ + public static final RetryStrategy LINEAR = linearBackoff(6, Duration.ofSeconds(1), Duration.ofSeconds(1)); + /** No retry strategy - fails immediately on first error. Use this for operations that should not be retried. */ public static final RetryStrategy NO_RETRY = (error, attempt) -> RetryDecision.fail(); } @@ -79,6 +82,33 @@ public static RetryStrategy exponentialBackoff( }; } + /** + * Creates a linear backoff retry strategy. + * + *

The delay calculation follows the formula: delay = initialDelay + increment × (attempt-1) + * + * @param maxAttempts Maximum number of attempts (including initial attempt) + * @param initialDelay Initial delay before first retry + * @param increment Amount to add to the delay after each retry attempt + * @return RetryStrategy implementing linear backoff + */ + public static RetryStrategy linearBackoff(int maxAttempts, Duration initialDelay, Duration increment) { + if (maxAttempts <= 0) { + throw new IllegalArgumentException("maxAttempts must be positive"); + } + ParameterValidator.validateDuration(initialDelay, "initialDelay"); + ParameterValidator.validateDuration(increment, "increment"); + + return (error, attempt) -> { + if (attempt >= maxAttempts) { + return RetryDecision.fail(); + } + + var delay = initialDelay.plus(increment.multipliedBy(attempt - 1)); + return RetryDecision.retry(delay); + }; + } + /** * Creates a simple retry strategy that retries a fixed number of times with a fixed delay. * diff --git a/sdk/src/test/java/software/amazon/lambda/durable/retry/RetryStrategiesTest.java b/sdk/src/test/java/software/amazon/lambda/durable/retry/RetryStrategiesTest.java index 1a9606092..84bd586bf 100644 --- a/sdk/src/test/java/software/amazon/lambda/durable/retry/RetryStrategiesTest.java +++ b/sdk/src/test/java/software/amazon/lambda/durable/retry/RetryStrategiesTest.java @@ -138,6 +138,104 @@ void testFixedDelayStrategy() { assertFalse(decision3.shouldRetry()); } + @Test + void linearBackoff_withCustomDelays_shouldIncreaseByIncrement() { + var strategy = RetryStrategies.linearBackoff(5, Duration.ofSeconds(2), Duration.ofSeconds(3)); + + var decision1 = strategy.makeRetryDecision(new RuntimeException("test"), 1); + var decision2 = strategy.makeRetryDecision(new RuntimeException("test"), 2); + var decision3 = strategy.makeRetryDecision(new RuntimeException("test"), 3); + var decision4 = strategy.makeRetryDecision(new RuntimeException("test"), 4); + + assertTrue(decision1.shouldRetry()); + assertEquals(Duration.ofSeconds(2), decision1.delay()); + + assertTrue(decision2.shouldRetry()); + assertEquals(Duration.ofSeconds(5), decision2.delay()); + + assertTrue(decision3.shouldRetry()); + assertEquals(Duration.ofSeconds(8), decision3.delay()); + + assertTrue(decision4.shouldRetry()); + assertEquals(Duration.ofSeconds(11), decision4.delay()); + } + + @Test + void linearPreset_shouldUseOneThroughFiveSecondDelays() { + var strategy = RetryStrategies.Presets.LINEAR; + + for (int attempt = 1; attempt <= 5; attempt++) { + var decision = strategy.makeRetryDecision(new RuntimeException("test"), attempt); + + assertTrue(decision.shouldRetry(), "Should retry on attempt " + attempt); + assertEquals(Duration.ofSeconds(attempt), decision.delay()); + } + + var finalDecision = strategy.makeRetryDecision(new RuntimeException("test"), 6); + assertFalse(finalDecision.shouldRetry()); + } + + @Test + void linearBackoff_shouldStopAtMaxAttempts() { + var strategy = RetryStrategies.linearBackoff(3, Duration.ofSeconds(1), Duration.ofSeconds(1)); + + var decision1 = strategy.makeRetryDecision(new RuntimeException("test"), 1); + var decision2 = strategy.makeRetryDecision(new RuntimeException("test"), 2); + var decision3 = strategy.makeRetryDecision(new RuntimeException("test"), 3); + + assertTrue(decision1.shouldRetry()); + assertTrue(decision2.shouldRetry()); + assertFalse(decision3.shouldRetry()); + } + + @Test + void linearBackoff_withInvalidMaxAttempts_shouldThrow() { + var exception = assertThrows( + IllegalArgumentException.class, + () -> RetryStrategies.linearBackoff(0, Duration.ofSeconds(1), Duration.ofSeconds(1))); + + assertTrue(exception.getMessage().contains("maxAttempts")); + assertTrue(exception.getMessage().contains("positive")); + } + + @Test + void linearBackoff_withSubSecondInitialDelay_shouldThrow() { + var exception = assertThrows( + IllegalArgumentException.class, + () -> RetryStrategies.linearBackoff(3, Duration.ofMillis(500), Duration.ofSeconds(1))); + + assertTrue(exception.getMessage().contains("initialDelay")); + assertTrue(exception.getMessage().contains("at least 1 second")); + } + + @Test + void linearBackoff_withNullInitialDelay_shouldThrow() { + var exception = assertThrows( + IllegalArgumentException.class, () -> RetryStrategies.linearBackoff(3, null, Duration.ofSeconds(1))); + + assertTrue(exception.getMessage().contains("initialDelay")); + assertTrue(exception.getMessage().contains("cannot be null")); + } + + @Test + void linearBackoff_withSubSecondIncrement_shouldThrow() { + var exception = assertThrows( + IllegalArgumentException.class, + () -> RetryStrategies.linearBackoff(3, Duration.ofSeconds(1), Duration.ofMillis(500))); + + assertTrue(exception.getMessage().contains("increment")); + assertTrue(exception.getMessage().contains("at least 1 second")); + } + + @Test + void linearBackoff_withNullIncrement_shouldThrow() { + var exception = assertThrows( + IllegalArgumentException.class, () -> RetryStrategies.linearBackoff(3, Duration.ofSeconds(1), null)); + + assertTrue(exception.getMessage().contains("increment")); + assertTrue(exception.getMessage().contains("cannot be null")); + } + @Test void testInvalidParameters() { assertThrows( From fb73e273289a202cbf3aeef3271b46edb9ea9181 Mon Sep 17 00:00:00 2001 From: Nanook Date: Sat, 20 Jun 2026 14:29:05 +0000 Subject: [PATCH 2/2] feat: add configurable linear retry jitter --- .../lambda/durable/retry/RetryStrategies.java | 71 ++++++++- .../durable/retry/RetryStrategiesTest.java | 136 ++++++++++++++++++ 2 files changed, 204 insertions(+), 3 deletions(-) diff --git a/sdk/src/main/java/software/amazon/lambda/durable/retry/RetryStrategies.java b/sdk/src/main/java/software/amazon/lambda/durable/retry/RetryStrategies.java index 250030542..b61aa3848 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/retry/RetryStrategies.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/retry/RetryStrategies.java @@ -9,7 +9,7 @@ * Factory class for creating common retry strategies. * *

This class provides preset retry strategies for common use cases, as well as factory methods for creating custom - * retry strategies with exponential backoff and jitter. + * retry strategies with exponential or linear backoff and jitter. */ public class RetryStrategies { @@ -28,8 +28,17 @@ public static class Presets { JitterStrategy.FULL // jitter ); - /** Linear retry strategy: 6 total attempts (1 initial + 5 retries) with 1s, 2s, 3s, 4s, and 5s delays. */ - public static final RetryStrategy LINEAR = linearBackoff(6, Duration.ofSeconds(1), Duration.ofSeconds(1)); + /** + * Linear retry strategy: - 6 total attempts (1 initial + 5 retries) - Initial delay: 1 second - Max delay: 5 + * seconds - Increment: 1 second - Jitter: NONE + */ + public static final RetryStrategy LINEAR = linearBackoff( + 6, // maxAttempts + Duration.ofSeconds(1), // initialDelay + Duration.ofSeconds(5), // maxDelay + Duration.ofSeconds(1), // increment + JitterStrategy.NONE // jitter + ); /** No retry strategy - fails immediately on first error. Use this for operations that should not be retried. */ public static final RetryStrategy NO_RETRY = (error, attempt) -> RetryDecision.fail(); @@ -109,6 +118,62 @@ public static RetryStrategy linearBackoff(int maxAttempts, Duration initialDelay }; } + /** + * Creates a linear backoff retry strategy. + * + *

The delay calculation follows the formula: baseDelay = min(initialDelay + increment × (attempt-1), maxDelay) + * + * @param maxAttempts Maximum number of attempts (including initial attempt) + * @param initialDelay Initial delay before first retry + * @param maxDelay Maximum delay between retries + * @param increment Amount to add to the delay after each retry attempt + * @param jitter Jitter strategy to apply to delays + * @return RetryStrategy implementing linear backoff with jitter + */ + public static RetryStrategy linearBackoff( + int maxAttempts, Duration initialDelay, Duration maxDelay, Duration increment, JitterStrategy jitter) { + if (maxAttempts <= 0) { + throw new IllegalArgumentException("maxAttempts must be positive"); + } + ParameterValidator.validateDuration(initialDelay, "initialDelay"); + ParameterValidator.validateDuration(maxDelay, "maxDelay"); + ParameterValidator.validateDuration(increment, "increment"); + if (jitter == null) { + throw new IllegalArgumentException("jitter cannot be null"); + } + + return (error, attempt) -> { + if (attempt >= maxAttempts) { + return RetryDecision.fail(); + } + + var baseDelay = calculateCappedLinearDelay(initialDelay, maxDelay, increment, attempt); + if (jitter == JitterStrategy.NONE) { + return RetryDecision.retry(baseDelay); + } + + var delayWithJitter = jitter.apply(baseDelay.toSeconds()); + var finalDelaySeconds = Math.max(1, Math.round(delayWithJitter)); + + return RetryDecision.retry(Duration.ofSeconds(finalDelaySeconds)); + }; + } + + private static Duration calculateCappedLinearDelay( + Duration initialDelay, Duration maxDelay, Duration increment, int attempt) { + if (initialDelay.compareTo(maxDelay) >= 0) { + return maxDelay; + } + + var increments = attempt - 1L; + var remaining = maxDelay.minus(initialDelay); + if (increments > remaining.dividedBy(increment)) { + return maxDelay; + } + + return initialDelay.plus(increment.multipliedBy(increments)); + } + /** * Creates a simple retry strategy that retries a fixed number of times with a fixed delay. * diff --git a/sdk/src/test/java/software/amazon/lambda/durable/retry/RetryStrategiesTest.java b/sdk/src/test/java/software/amazon/lambda/durable/retry/RetryStrategiesTest.java index 84bd586bf..ea12ce6d1 100644 --- a/sdk/src/test/java/software/amazon/lambda/durable/retry/RetryStrategiesTest.java +++ b/sdk/src/test/java/software/amazon/lambda/durable/retry/RetryStrategiesTest.java @@ -160,6 +160,109 @@ void linearBackoff_withCustomDelays_shouldIncreaseByIncrement() { assertEquals(Duration.ofSeconds(11), decision4.delay()); } + @Test + void linearBackoff_withOldOverload_shouldRemainUncappedAndDeterministic() { + var strategy = RetryStrategies.linearBackoff(5, Duration.ofSeconds(10), Duration.ofSeconds(10)); + + assertEquals( + Duration.ofSeconds(10), + strategy.makeRetryDecision(new RuntimeException("test"), 1).delay()); + assertEquals( + Duration.ofSeconds(20), + strategy.makeRetryDecision(new RuntimeException("test"), 2).delay()); + assertEquals( + Duration.ofSeconds(30), + strategy.makeRetryDecision(new RuntimeException("test"), 3).delay()); + assertEquals( + Duration.ofSeconds(40), + strategy.makeRetryDecision(new RuntimeException("test"), 4).delay()); + } + + @Test + void linearBackoff_withMaxDelay_shouldCapDelay() { + var strategy = RetryStrategies.linearBackoff( + 6, Duration.ofSeconds(2), Duration.ofSeconds(7), Duration.ofSeconds(3), JitterStrategy.NONE); + + assertEquals( + Duration.ofSeconds(2), + strategy.makeRetryDecision(new RuntimeException("test"), 1).delay()); + assertEquals( + Duration.ofSeconds(5), + strategy.makeRetryDecision(new RuntimeException("test"), 2).delay()); + assertEquals( + Duration.ofSeconds(7), + strategy.makeRetryDecision(new RuntimeException("test"), 3).delay()); + assertEquals( + Duration.ofSeconds(7), + strategy.makeRetryDecision(new RuntimeException("test"), 4).delay()); + } + + @Test + void linearBackoff_withMaxDelay_shouldCapBeforeOverflow() { + var strategy = RetryStrategies.linearBackoff( + Integer.MAX_VALUE, + Duration.ofSeconds(1), + Duration.ofSeconds(5), + Duration.ofSeconds(Long.MAX_VALUE), + JitterStrategy.NONE); + + var decision = assertDoesNotThrow(() -> strategy.makeRetryDecision(new RuntimeException("test"), 2)); + + assertTrue(decision.shouldRetry()); + assertEquals(Duration.ofSeconds(5), decision.delay()); + } + + @Test + void linearBackoff_withNewOverload_shouldStopAtMaxAttempts() { + var strategy = RetryStrategies.linearBackoff( + 3, Duration.ofSeconds(1), Duration.ofSeconds(5), Duration.ofSeconds(1), JitterStrategy.NONE); + + var decision1 = strategy.makeRetryDecision(new RuntimeException("test"), 1); + var decision2 = strategy.makeRetryDecision(new RuntimeException("test"), 2); + var decision3 = strategy.makeRetryDecision(new RuntimeException("test"), 3); + + assertTrue(decision1.shouldRetry()); + assertEquals(Duration.ofSeconds(1), decision1.delay()); + assertTrue(decision2.shouldRetry()); + assertEquals(Duration.ofSeconds(2), decision2.delay()); + assertFalse(decision3.shouldRetry()); + } + + @Test + void linearBackoff_withMaxDelayLessThanInitialDelay_shouldCapFirstRetry() { + var strategy = RetryStrategies.linearBackoff( + 4, Duration.ofSeconds(10), Duration.ofSeconds(5), Duration.ofSeconds(3), JitterStrategy.NONE); + + assertEquals( + Duration.ofSeconds(5), + strategy.makeRetryDecision(new RuntimeException("test"), 1).delay()); + assertEquals( + Duration.ofSeconds(5), + strategy.makeRetryDecision(new RuntimeException("test"), 2).delay()); + } + + @Test + void linearBackoff_withJitter_shouldProduceDelayInExpectedRange() { + var fullStrategy = RetryStrategies.linearBackoff( + 5, Duration.ofSeconds(10), Duration.ofSeconds(15), Duration.ofSeconds(10), JitterStrategy.FULL); + var halfStrategy = RetryStrategies.linearBackoff( + 5, Duration.ofSeconds(10), Duration.ofSeconds(15), Duration.ofSeconds(10), JitterStrategy.HALF); + + for (int i = 0; i < 10; i++) { + var fullDelay = fullStrategy + .makeRetryDecision(new RuntimeException("test"), 2) + .delay() + .toSeconds(); + assertTrue(fullDelay >= 1 && fullDelay <= 15); + + var halfDelay = halfStrategy + .makeRetryDecision(new RuntimeException("test"), 2) + .delay() + .toSeconds(); + assertTrue(halfDelay >= 8 && halfDelay <= 15); + } + } + @Test void linearPreset_shouldUseOneThroughFiveSecondDelays() { var strategy = RetryStrategies.Presets.LINEAR; @@ -236,6 +339,39 @@ void linearBackoff_withNullIncrement_shouldThrow() { assertTrue(exception.getMessage().contains("cannot be null")); } + @Test + void linearBackoff_withSubSecondMaxDelay_shouldThrow() { + var exception = assertThrows( + IllegalArgumentException.class, + () -> RetryStrategies.linearBackoff( + 3, Duration.ofSeconds(1), Duration.ofMillis(500), Duration.ofSeconds(1), JitterStrategy.NONE)); + + assertTrue(exception.getMessage().contains("maxDelay")); + assertTrue(exception.getMessage().contains("at least 1 second")); + } + + @Test + void linearBackoff_withNullMaxDelay_shouldThrow() { + var exception = assertThrows( + IllegalArgumentException.class, + () -> RetryStrategies.linearBackoff( + 3, Duration.ofSeconds(1), null, Duration.ofSeconds(1), JitterStrategy.NONE)); + + assertTrue(exception.getMessage().contains("maxDelay")); + assertTrue(exception.getMessage().contains("cannot be null")); + } + + @Test + void linearBackoff_withNullJitter_shouldThrow() { + var exception = assertThrows( + IllegalArgumentException.class, + () -> RetryStrategies.linearBackoff( + 3, Duration.ofSeconds(1), Duration.ofSeconds(5), Duration.ofSeconds(1), null)); + + assertTrue(exception.getMessage().contains("jitter")); + assertTrue(exception.getMessage().contains("cannot be null")); + } + @Test void testInvalidParameters() { assertThrows(