Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* Factory class for creating common retry strategies.
*
* <p>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 {

Expand All @@ -28,6 +28,18 @@ public static class Presets {
JitterStrategy.FULL // jitter
);

/**
* 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();
}
Expand Down Expand Up @@ -79,6 +91,89 @@ public static RetryStrategy exponentialBackoff(
};
}

/**
* Creates a linear backoff retry strategy.
*
* <p>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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be more consistent with exponentialBackoff and have a maxDelay and jitterStrategy

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want a jitter for this strategy?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like reference JS implementation doesn't have it #513, but the python SDK just added an implementation with jitter #484.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nevermind, they're aligned now #652

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, basically the preset LINEAR may not need a jitter but the RetryStrategy factory linearBackoff should support customizable jitter

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 linear backoff retry strategy.
*
* <p>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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,240 @@ 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 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;

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 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(
Expand Down