Browse Source

Introduce onRetryPolicyInterruption() callback in RetryListener

In RetryTemplate, if we encounter an InterruptedException while
sleeping for the configured back-off duration, we throw a
RetryException with the InterruptedException as the cause.

However, prior to this commit, that RetryException propagated to the
caller without notifying the registered RetryListener.

To address that, this commit introduces a new
onRetryPolicyInterruption() callback in RetryListener as a companion to
the existing onRetryPolicyExhaustion() callback.

Closes gh-35442
pull/35447/head
Sam Brannen 7 months ago
parent
commit
b2cdfbadf1
  1. 22
      spring-core/src/main/java/org/springframework/core/retry/RetryListener.java
  2. 1
      spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java
  3. 12
      spring-core/src/main/java/org/springframework/core/retry/support/CompositeRetryListener.java
  4. 33
      spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java
  5. 10
      spring-core/src/test/java/org/springframework/core/retry/support/CompositeRetryListenerTests.java

22
spring-core/src/main/java/org/springframework/core/retry/RetryListener.java

@ -53,7 +53,7 @@ public interface RetryListener { @@ -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 { @@ -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 { @@ -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) {
}
}

1
spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java

@ -168,6 +168,7 @@ public class RetryTemplate implements RetryOperations { @@ -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));

12
spring-core/src/main/java/org/springframework/core/retry/support/CompositeRetryListener.java

@ -29,13 +29,14 @@ import org.springframework.core.retry.Retryable; @@ -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}.
*
* <p>This class is used to compose multiple listeners within a {@link RetryTemplate}.
* <p>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 { @@ -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));
}
}

33
spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java

@ -30,8 +30,11 @@ import org.junit.jupiter.api.Test; @@ -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 { @@ -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<String> 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();

10
spring-core/src/test/java/org/springframework/core/retry/support/CompositeRetryListenerTests.java

@ -92,4 +92,14 @@ class CompositeRetryListenerTests { @@ -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);
}
}

Loading…
Cancel
Save