predicate, Duration timeout, BackOff backOff) {
this.includes = includes;
this.excludes = excludes;
this.exceptionFilter = new ExceptionTypeFilter(this.includes, this.excludes);
this.predicate = predicate;
+ this.timeout = timeout;
this.backOff = backOff;
}
@@ -62,6 +66,11 @@ class DefaultRetryPolicy implements RetryPolicy {
(this.predicate == null || this.predicate.test(throwable)));
}
+ @Override
+ public Duration getTimeout() {
+ return this.timeout;
+ }
+
@Override
public BackOff getBackOff() {
return this.backOff;
diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryListener.java b/spring-core/src/main/java/org/springframework/core/retry/RetryListener.java
index 7bfab43f979..f237751f63a 100644
--- a/spring-core/src/main/java/org/springframework/core/retry/RetryListener.java
+++ b/spring-core/src/main/java/org/springframework/core/retry/RetryListener.java
@@ -87,4 +87,20 @@ public interface RetryListener {
default void onRetryPolicyInterruption(RetryPolicy retryPolicy, Retryable> retryable, RetryException exception) {
}
+ /**
+ * Called if the configured {@linkplain RetryPolicy#getTimeout() timeout} for
+ * a {@link RetryPolicy} is exceeded.
+ * @param retryPolicy the {@code RetryPolicy}
+ * @param retryable the {@link Retryable} operation
+ * @param exception the resulting {@link RetryException}, with the last
+ * exception thrown by the {@code Retryable} operation as the cause and any
+ * exceptions from previous attempts as suppressed exceptions
+ * @since 7.0.2
+ * @see RetryException#getCause()
+ * @see RetryException#getSuppressed()
+ * @see RetryException#getRetryCount()
+ */
+ default void onRetryPolicyTimeout(RetryPolicy retryPolicy, Retryable> retryable, RetryException exception) {
+ }
+
}
diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryPolicy.java b/spring-core/src/main/java/org/springframework/core/retry/RetryPolicy.java
index ebc9278b4db..5c295436031 100644
--- a/spring-core/src/main/java/org/springframework/core/retry/RetryPolicy.java
+++ b/spring-core/src/main/java/org/springframework/core/retry/RetryPolicy.java
@@ -55,6 +55,21 @@ public interface RetryPolicy {
*/
boolean shouldRetry(Throwable throwable);
+ /**
+ * Get the timeout to use for this retry policy.
+ * The returned {@link Duration} represents the maximum amount of elapsed
+ * time allowed for the initial invocation and any subsequent retry attempts,
+ * including delays.
+ *
Defaults to {@link Duration#ZERO} which signals that no timeout should
+ * be applied.
+ * @return the timeout to apply
+ * @since 7.0.2
+ * @see Builder#timeout(Duration)
+ */
+ default Duration getTimeout() {
+ return Duration.ZERO;
+ }
+
/**
* Get the {@link BackOff} strategy to use for this retry policy.
*
Defaults to a fixed backoff of {@value Builder#DEFAULT_DELAY} milliseconds
@@ -158,6 +173,8 @@ public interface RetryPolicy {
private @Nullable Long maxRetries;
+ private Duration timeout = Duration.ZERO;
+
private @Nullable Duration delay;
private @Nullable Duration jitter;
@@ -214,6 +231,24 @@ public interface RetryPolicy {
return this;
}
+ /**
+ * Specify a timeout for the maximum amount of elapsed time allowed for
+ * the initial invocation and any subsequent retry attempts, including
+ * delays.
+ *
The default is {@link Duration#ZERO}, which signals that no timeout
+ * should be applied.
+ *
The supplied value will override any previously configured value.
+ * @param timeout the timeout, typically in milliseconds or seconds;
+ * must be greater than or equal to zero
+ * @return this {@code Builder} instance for chained method invocations
+ * @since 7.0.2
+ */
+ public Builder timeout(Duration timeout) {
+ assertIsNotNegative("timeout", timeout);
+ this.timeout = timeout;
+ return this;
+ }
+
/**
* Specify the base delay after the initial invocation.
*
If a {@linkplain #multiplier(double) multiplier} is specified, this
@@ -441,7 +476,7 @@ public interface RetryPolicy {
}
backOff = exponentialBackOff;
}
- return new DefaultRetryPolicy(this.includes, this.excludes, this.predicate, backOff);
+ return new DefaultRetryPolicy(this.includes, this.excludes, this.predicate, this.timeout, backOff);
}
}
diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java b/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java
index 1870a7632e6..95b024e8370 100644
--- a/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java
+++ b/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java
@@ -136,6 +136,7 @@ public class RetryTemplate implements RetryOperations {
@Override
public R execute(Retryable retryable) throws RetryException {
String retryableName = retryable.getName();
+ long startTime = System.currentTimeMillis();
// Initial attempt
try {
logger.debug(() -> "Preparing to execute retryable operation '%s'".formatted(retryableName));
@@ -153,12 +154,15 @@ public class RetryTemplate implements RetryOperations {
exceptions.add(initialException);
Throwable lastException = initialException;
+ long timeout = this.retryPolicy.getTimeout().toMillis();
while (this.retryPolicy.shouldRetry(lastException)) {
+ checkIfTimeoutExceeded(timeout, startTime, 0, retryable, exceptions);
try {
long sleepTime = backOffExecution.nextBackOff();
if (sleepTime == BackOffExecution.STOP) {
break;
}
+ checkIfTimeoutExceeded(timeout, startTime, sleepTime, retryable, exceptions);
logger.debug(() -> "Backing off for %dms after retryable operation '%s'"
.formatted(sleepTime, retryableName));
Thread.sleep(sleepTime);
@@ -201,6 +205,24 @@ public class RetryTemplate implements RetryOperations {
}
}
+ private void checkIfTimeoutExceeded(long timeout, long startTime, long sleepTime, Retryable> retryable,
+ Deque exceptions) throws RetryException {
+
+ if (timeout != 0) {
+ // If sleepTime > 0, we are predicting what the effective elapsed time
+ // would be if we were to sleep for sleepTime milliseconds.
+ long elapsedTime = System.currentTimeMillis() + sleepTime - startTime;
+ if (elapsedTime >= timeout) {
+ RetryException retryException = new RetryException(
+ "Retry policy for operation '%s' exceeded timeout (%d ms); aborting execution"
+ .formatted(retryable.getName(), timeout), exceptions.removeLast());
+ exceptions.forEach(retryException::addSuppressed);
+ this.retryListener.onRetryPolicyTimeout(this.retryPolicy, retryable, retryException);
+ throw retryException;
+ }
+ }
+ }
+
private static class RetryInterruptedException extends RetryException {
diff --git a/spring-core/src/main/java/org/springframework/core/retry/support/CompositeRetryListener.java b/spring-core/src/main/java/org/springframework/core/retry/support/CompositeRetryListener.java
index cdf02bf33f3..5d3c7a62665 100644
--- a/spring-core/src/main/java/org/springframework/core/retry/support/CompositeRetryListener.java
+++ b/spring-core/src/main/java/org/springframework/core/retry/support/CompositeRetryListener.java
@@ -95,4 +95,9 @@ public class CompositeRetryListener implements RetryListener {
this.listeners.forEach(listener -> listener.onRetryPolicyInterruption(retryPolicy, retryable, exception));
}
+ @Override
+ public void onRetryPolicyTimeout(RetryPolicy retryPolicy, Retryable> retryable, RetryException exception) {
+ this.listeners.forEach(listener -> listener.onRetryPolicyTimeout(retryPolicy, retryable, exception));
+ }
+
}
diff --git a/spring-core/src/test/java/org/springframework/core/retry/RetryPolicyTests.java b/spring-core/src/test/java/org/springframework/core/retry/RetryPolicyTests.java
index 1aeaa303c43..4d6ffb637c8 100644
--- a/spring-core/src/test/java/org/springframework/core/retry/RetryPolicyTests.java
+++ b/spring-core/src/test/java/org/springframework/core/retry/RetryPolicyTests.java
@@ -131,6 +131,22 @@ class RetryPolicyTests {
assertToString(policy, 1000, 0, 1.0, Long.MAX_VALUE, 5);
}
+ @Test
+ void timeoutPreconditions() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> RetryPolicy.builder().timeout(Duration.ofMillis(-1)))
+ .withMessage("Invalid timeout (-1ms): must be greater than or equal to zero.");
+ }
+
+ @Test
+ void timeout() {
+ Duration timeout = Duration.ofMillis(42);
+
+ var policy = RetryPolicy.builder().timeout(timeout).build();
+
+ assertThat(policy.getTimeout()).isSameAs(timeout);
+ }
+
@Test
void delayPreconditions() {
assertThatIllegalArgumentException()
diff --git a/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java b/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java
index 0ed720d77e4..dd1190afc04 100644
--- a/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java
+++ b/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java
@@ -26,6 +26,7 @@ import java.util.function.Consumer;
import org.assertj.core.api.ThrowingConsumer;
import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments.ArgumentSet;
@@ -407,6 +408,185 @@ class RetryTemplateTests {
}
+ @Nested
+ class TimeoutTests {
+
+ @Test
+ void retryWithImmediateSuccessAndTimeoutExceeded() throws Exception {
+ RetryPolicy retryPolicy = RetryPolicy.builder().timeout(Duration.ofMillis(5)).build();
+ RetryTemplate retryTemplate = new RetryTemplate(retryPolicy);
+ retryTemplate.setRetryListener(retryListener);
+
+ AtomicInteger invocationCount = new AtomicInteger();
+ Retryable retryable = () -> {
+ invocationCount.incrementAndGet();
+ Thread.sleep(10);
+ return "always succeeds";
+ };
+
+ assertThat(invocationCount).hasValue(0);
+ assertThat(retryTemplate.execute(retryable)).isEqualTo("always succeeds");
+ assertThat(invocationCount).hasValue(1);
+
+ // RetryListener interactions:
+ verifyNoInteractions(retryListener);
+ }
+
+ @Test
+ void retryWithInitialFailureAndZeroRetriesRetryPolicyAndTimeoutExceeded() {
+ RetryPolicy retryPolicy = RetryPolicy.builder()
+ .timeout(Duration.ofMillis(5))
+ .predicate(throwable -> false) // Zero retries
+ .build();
+ RetryTemplate retryTemplate = new RetryTemplate(retryPolicy);
+ retryTemplate.setRetryListener(retryListener);
+
+ Exception exception = new RuntimeException("Boom!");
+ Retryable retryable = () -> {
+ Thread.sleep(10);
+ throw exception;
+ };
+
+ assertThatExceptionOfType(RetryException.class)
+ .isThrownBy(() -> retryTemplate.execute(retryable))
+ .withMessageMatching("Retry policy for operation '.+?' exhausted; aborting execution")
+ .withCause(exception)
+ .satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty())
+ .satisfies(throwable -> assertThat(throwable.getRetryCount()).isZero())
+ .satisfies(throwable -> inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, throwable));
+
+ verifyNoMoreInteractions(retryListener);
+ }
+
+ @Test
+ void retryWithTimeoutExceededAfterInitialFailure() throws Exception {
+ RetryPolicy retryPolicy = RetryPolicy.builder()
+ .timeout(Duration.ofMillis(5))
+ .delay(Duration.ZERO)
+ .build();
+ RetryTemplate retryTemplate = new RetryTemplate(retryPolicy);
+ retryTemplate.setRetryListener(retryListener);
+
+ AtomicInteger invocationCount = new AtomicInteger();
+ Retryable retryable = () -> {
+ Thread.sleep(10);
+ throw new CustomException("Boom " + invocationCount.incrementAndGet());
+ };
+
+ assertThat(invocationCount).hasValue(0);
+ assertThatExceptionOfType(RetryException.class)
+ .isThrownBy(() -> retryTemplate.execute(retryable))
+ .withMessageMatching("Retry policy for operation '.+?' exceeded timeout \\(5 ms\\); aborting execution")
+ .withCause(new CustomException("Boom 1"))
+ .satisfies(throwable -> inOrder.verify(retryListener).onRetryPolicyTimeout(
+ eq(retryPolicy), eq(retryable), eq(throwable)));
+ assertThat(invocationCount).hasValue(1);
+
+ verifyNoMoreInteractions(retryListener);
+ }
+
+ @Test
+ void retryWithTimeoutExceededAfterFirstDelayButBeforeFirstRetry() throws Exception {
+ RetryPolicy retryPolicy = RetryPolicy.builder()
+ .timeout(Duration.ofMillis(5))
+ .delay(Duration.ofMillis(10)) // Delay > Timeout
+ .build();
+ RetryTemplate retryTemplate = new RetryTemplate(retryPolicy);
+ retryTemplate.setRetryListener(retryListener);
+
+ AtomicInteger invocationCount = new AtomicInteger();
+ Retryable retryable = () -> {
+ throw new CustomException("Boom " + invocationCount.incrementAndGet());
+ };
+
+ assertThat(invocationCount).hasValue(0);
+ assertThatExceptionOfType(RetryException.class)
+ .isThrownBy(() -> retryTemplate.execute(retryable))
+ .withMessageMatching("Retry policy for operation '.+?' exceeded timeout \\(5 ms\\); aborting execution")
+ .withCause(new CustomException("Boom 1"))
+ .satisfies(throwable -> inOrder.verify(retryListener).onRetryPolicyTimeout(
+ eq(retryPolicy), eq(retryable), eq(throwable)));
+ assertThat(invocationCount).hasValue(1);
+
+ verifyNoMoreInteractions(retryListener);
+ }
+
+ @Test
+ void retryWithTimeoutExceededAfterFirstRetry() throws Exception {
+ RetryPolicy retryPolicy = RetryPolicy.builder()
+ .timeout(Duration.ofMillis(5))
+ .delay(Duration.ZERO)
+ .build();
+ RetryTemplate retryTemplate = new RetryTemplate(retryPolicy);
+ retryTemplate.setRetryListener(retryListener);
+
+ AtomicInteger invocationCount = new AtomicInteger();
+ Retryable retryable = () -> {
+ int currentInvocation = invocationCount.incrementAndGet();
+ if (currentInvocation == 2) {
+ Thread.sleep(10);
+ }
+ throw new CustomException("Boom " + currentInvocation);
+ };
+
+ assertThat(invocationCount).hasValue(0);
+ assertThatExceptionOfType(RetryException.class)
+ .isThrownBy(() -> retryTemplate.execute(retryable))
+ .withMessageMatching("Retry policy for operation '.+?' exceeded timeout \\(5 ms\\); aborting execution")
+ .withCause(new CustomException("Boom 2"))
+ .satisfies(throwable -> {
+ inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable);
+ inOrder.verify(retryListener).onRetryFailure(retryPolicy, retryable, new CustomException("Boom 2"));
+
+ inOrder.verify(retryListener).onRetryPolicyTimeout(
+ eq(retryPolicy), eq(retryable), eq(throwable));
+ });
+ assertThat(invocationCount).hasValue(2);
+
+ verifyNoMoreInteractions(retryListener);
+ }
+
+ @Test
+ void retryWithTimeoutExceededAfterSecondRetry() throws Exception {
+ RetryPolicy retryPolicy = RetryPolicy.builder()
+ .timeout(Duration.ofMillis(5))
+ .delay(Duration.ZERO)
+ .build();
+ RetryTemplate retryTemplate = new RetryTemplate(retryPolicy);
+ retryTemplate.setRetryListener(retryListener);
+
+ AtomicInteger invocationCount = new AtomicInteger();
+ Retryable retryable = () -> {
+ int currentInvocation = invocationCount.incrementAndGet();
+ if (currentInvocation == 3) {
+ Thread.sleep(10);
+ }
+ throw new CustomException("Boom " + currentInvocation);
+ };
+
+ assertThat(invocationCount).hasValue(0);
+ assertThatExceptionOfType(RetryException.class)
+ .isThrownBy(() -> retryTemplate.execute(retryable))
+ .withMessageMatching("Retry policy for operation '.+?' exceeded timeout \\(5 ms\\); aborting execution")
+ .withCause(new CustomException("Boom 3"))
+ .satisfies(throwable -> {
+ var counter = new AtomicInteger(1);
+ repeat(2, () -> {
+ inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable);
+ inOrder.verify(retryListener).onRetryFailure(retryPolicy, retryable,
+ new CustomException("Boom " + counter.incrementAndGet()));
+ });
+ inOrder.verify(retryListener).onRetryPolicyTimeout(
+ eq(retryPolicy), eq(retryable), eq(throwable));
+ });
+ assertThat(invocationCount).hasValue(3);
+
+ verifyNoMoreInteractions(retryListener);
+ }
+
+ }
+
+
private static void repeat(int times, Runnable runnable) {
for (int i = 0; i < times; i++) {
runnable.run();
diff --git a/spring-core/src/test/java/org/springframework/core/retry/support/CompositeRetryListenerTests.java b/spring-core/src/test/java/org/springframework/core/retry/support/CompositeRetryListenerTests.java
index 3ceb0e9bb60..e7fa3741a8a 100644
--- a/spring-core/src/test/java/org/springframework/core/retry/support/CompositeRetryListenerTests.java
+++ b/spring-core/src/test/java/org/springframework/core/retry/support/CompositeRetryListenerTests.java
@@ -16,6 +16,7 @@
package org.springframework.core.retry.support;
+import java.time.Duration;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
@@ -102,4 +103,15 @@ class CompositeRetryListenerTests {
verify(listener3).onRetryPolicyInterruption(retryPolicy, retryable, exception);
}
+ @Test
+ void onRetryPolicyTimeout() {
+ Duration elapsedTime = Duration.ofMillis(100);
+ RetryException exception = new RetryException("", new Exception());
+ compositeRetryListener.onRetryPolicyTimeout(retryPolicy, retryable, exception);
+
+ verify(listener1).onRetryPolicyTimeout(retryPolicy, retryable, exception);
+ verify(listener2).onRetryPolicyTimeout(retryPolicy, retryable, exception);
+ verify(listener3).onRetryPolicyTimeout(retryPolicy, retryable, exception);
+ }
+
}