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 2dd1c1b38a4..36aed999201 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 @@ -53,7 +53,7 @@ public interface RetryListener { } /** - * Called every time a retry attempt fails. + * Called after every failed retry attempt. * @param retryPolicy the {@link RetryPolicy} * @param retryable the {@link Retryable} operation * @param throwable the exception thrown by the {@code Retryable} operation @@ -65,8 +65,9 @@ public interface RetryListener { * Called if the {@link RetryPolicy} is exhausted. * @param retryPolicy the {@code RetryPolicy} * @param retryable the {@code Retryable} operation - * @param exception the resulting {@link RetryException}, including the last operation - * exception as a cause and all earlier operation exceptions as suppressed exceptions + * @param exception the resulting {@link RetryException}, with the last + * exception thrown by the {@link Retryable} operation as the cause and any + * exceptions from previous attempts as suppressed exceptions * @see RetryException#getCause() * @see RetryException#getSuppressed() * @see RetryException#getRetryCount() @@ -74,4 +75,19 @@ public interface RetryListener { default void onRetryPolicyExhaustion(RetryPolicy retryPolicy, Retryable retryable, RetryException exception) { } + /** + * Called if an {@link InterruptedException} is encountered while + * {@linkplain Thread#sleep(long) sleeping} between retry attempts. + * @param retryPolicy the {@code RetryPolicy} + * @param retryable the {@code Retryable} operation + * @param exception the resulting {@link RetryException}, with the + * {@code InterruptedException} as the cause and any exceptions from previous + * retry attempts as suppressed exceptions + * @see RetryException#getCause() + * @see RetryException#getSuppressed() + * @see RetryException#getRetryCount() + */ + default void onRetryPolicyInterruption(RetryPolicy retryPolicy, Retryable retryable, RetryException exception) { + } + } 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 bf55b724c67..9d374d5e467 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 @@ -168,6 +168,7 @@ public class RetryTemplate implements RetryOperations { "Unable to back off for retryable operation '%s'".formatted(retryableName), interruptedException); exceptions.forEach(retryException::addSuppressed); + this.retryListener.onRetryPolicyInterruption(this.retryPolicy, retryable, retryException); throw retryException; } logger.debug(() -> "Preparing to retry operation '%s'".formatted(retryableName)); 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 219ab7b605c..c9b16865a5b 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 @@ -29,13 +29,14 @@ import org.springframework.core.retry.Retryable; import org.springframework.util.Assert; /** - * A composite implementation of the {@link RetryListener} interface. - * Delegate listeners will be called in their registration order. + * A composite implementation of the {@link RetryListener} interface, which is + * used to compose multiple listeners within a {@link RetryTemplate}. * - *

This class is used to compose multiple listeners within a {@link RetryTemplate}. + *

Delegate listeners will be called in their registration order. * * @author Mahmoud Ben Hassine * @author Juergen Hoeller + * @author Sam Brannen * @since 7.0 */ public class CompositeRetryListener implements RetryListener { @@ -88,4 +89,9 @@ public class CompositeRetryListener implements RetryListener { this.listeners.forEach(listener -> listener.onRetryPolicyExhaustion(retryPolicy, retryable, exception)); } + @Override + public void onRetryPolicyInterruption(RetryPolicy retryPolicy, Retryable retryable, RetryException exception) { + this.listeners.forEach(listener -> listener.onRetryPolicyInterruption(retryPolicy, retryable, exception)); + } + } 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 d2e32f6c441..8e8bbaa2fcf 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 @@ -30,8 +30,11 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments.ArgumentSet; import org.junit.jupiter.params.provider.FieldSource; +import org.junit.platform.commons.util.ExceptionUtils; import org.mockito.InOrder; +import org.springframework.util.backoff.BackOff; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.junit.jupiter.params.provider.Arguments.argumentSet; @@ -213,6 +216,36 @@ class RetryTemplateTests { verifyNoMoreInteractions(retryListener); } + @Test + void retryWithInterruptionDuringSleep() { + Exception exception = new RuntimeException("Boom!"); + InterruptedException interruptedException = new InterruptedException(); + + // Simulates interruption during sleep: + BackOff backOff = () -> () -> { + throw ExceptionUtils.throwAsUncheckedException(interruptedException); + }; + + RetryPolicy retryPolicy = RetryPolicy.builder().backOff(backOff).build(); + RetryTemplate retryTemplate = new RetryTemplate(retryPolicy); + retryTemplate.setRetryListener(retryListener); + Retryable retryable = () -> { + throw exception; + }; + + assertThatExceptionOfType(RetryException.class) + .isThrownBy(() -> retryTemplate.execute(retryable)) + .withMessageMatching("Unable to back off for retryable operation '.+?'") + .withCause(interruptedException) + .satisfies(throwable -> assertThat(throwable.getSuppressed()).containsExactly(exception)) + // TODO Fix retry count for InterruptedException scenario. + // Retry count should actually be 0. + .satisfies(throwable -> assertThat(throwable.getRetryCount()).isEqualTo(1)) + .satisfies(throwable -> inOrder.verify(retryListener).onRetryPolicyInterruption(retryPolicy, retryable, throwable)); + + verifyNoMoreInteractions(retryListener); + } + @Test void retryWithFailingRetryableAndMultiplePredicates() { var invocationCount = new AtomicInteger(); 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 10bb628f257..3ceb0e9bb60 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 @@ -92,4 +92,14 @@ class CompositeRetryListenerTests { verify(listener3).onRetryPolicyExhaustion(retryPolicy, retryable, exception); } + @Test + void onRetryPolicyInterruption() { + RetryException exception = new RetryException("", new Exception()); + compositeRetryListener.onRetryPolicyInterruption(retryPolicy, retryable, exception); + + verify(listener1).onRetryPolicyInterruption(retryPolicy, retryable, exception); + verify(listener2).onRetryPolicyInterruption(retryPolicy, retryable, exception); + verify(listener3).onRetryPolicyInterruption(retryPolicy, retryable, exception); + } + }