Browse Source

Introduce RetryListener#onRetryableExecution callback with RetryState

Closes gh-35940
pull/35990/head
Juergen Hoeller 1 week ago
parent
commit
adcd7cb4cb
  1. 55
      spring-core/src/main/java/org/springframework/core/retry/RetryException.java
  2. 32
      spring-core/src/main/java/org/springframework/core/retry/RetryListener.java
  3. 69
      spring-core/src/main/java/org/springframework/core/retry/RetryState.java
  4. 98
      spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java
  5. 6
      spring-core/src/main/java/org/springframework/core/retry/support/CompositeRetryListener.java
  6. 73
      spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java
  7. 10
      spring-core/src/test/java/org/springframework/core/retry/support/CompositeRetryListenerTests.java

55
spring-core/src/main/java/org/springframework/core/retry/RetryException.java

@ -17,6 +17,9 @@
package org.springframework.core.retry; package org.springframework.core.retry;
import java.io.Serial; import java.io.Serial;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects; import java.util.Objects;
/** /**
@ -27,13 +30,8 @@ import java.util.Objects;
* any exceptions from previous attempts as {@linkplain #getSuppressed() suppressed * any exceptions from previous attempts as {@linkplain #getSuppressed() suppressed
* exceptions}. * exceptions}.
* *
* <p>However, if an {@link InterruptedException} is encountered while * <p>Implements the {@link RetryState} interface for exposing the final outcome,
* {@linkplain Thread#sleep(long) sleeping} for the current * as a parameter of the terminal listener methods on {@link RetryListener}.
* {@link org.springframework.util.backoff.BackOff BackOff} duration, a
* {@code RetryException} will contain the {@code InterruptedException} as the
* {@linkplain #getCause() cause} and any exceptions from previous invocations
* of the {@code Retryable} operation as {@linkplain #getSuppressed() suppressed
* exceptions}.
* *
* @author Mahmoud Ben Hassine * @author Mahmoud Ben Hassine
* @author Juergen Hoeller * @author Juergen Hoeller
@ -41,7 +39,7 @@ import java.util.Objects;
* @since 7.0 * @since 7.0
* @see RetryOperations * @see RetryOperations
*/ */
public class RetryException extends Exception { public class RetryException extends Exception implements RetryState {
@Serial @Serial
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
@ -50,14 +48,26 @@ public class RetryException extends Exception {
/** /**
* Create a new {@code RetryException} for the supplied message and cause. * Create a new {@code RetryException} for the supplied message and cause.
* @param message the detail message * @param message the detail message
* @param cause the last exception thrown by the {@link Retryable} operation, * @param cause the last exception thrown by the {@link Retryable} operation
* or an {@link InterruptedException} thrown while sleeping for the current
* {@code BackOff} duration
*/ */
public RetryException(String message, Throwable cause) { public RetryException(String message, Throwable cause) {
super(message, Objects.requireNonNull(cause, "cause must not be null")); super(message, Objects.requireNonNull(cause, "cause must not be null"));
} }
/**
* Create a new {@code RetryException} for the supplied message and state.
* @param message the detail message
* @param retryState the final retry state
* @since 7.0.2
*/
RetryException(String message, RetryState retryState) {
super(message, retryState.getLastException());
List<Throwable> exceptions = retryState.getExceptions();
for (int i = 0; i < exceptions.size() - 1; i++) {
addSuppressed(exceptions.get(i));
}
}
/** /**
* Get the last exception thrown by the {@link Retryable} operation, or an * Get the last exception thrown by the {@link Retryable} operation, or an
@ -73,8 +83,31 @@ public class RetryException extends Exception {
* Return the number of retry attempts, or 0 if no retry has been attempted * Return the number of retry attempts, or 0 if no retry has been attempted
* after the initial invocation at all. * after the initial invocation at all.
*/ */
@Override
public int getRetryCount() { public int getRetryCount() {
return getSuppressed().length; return getSuppressed().length;
} }
/**
* Return all invocation exceptions encountered, in the order of occurrence.
* @since 7.0.2
*/
@Override
public List<Throwable> getExceptions() {
Throwable[] suppressed = getSuppressed();
List<Throwable> exceptions = new ArrayList<>(suppressed.length + 1);
Collections.addAll(exceptions, suppressed);
exceptions.add(getCause());
return Collections.unmodifiableList(exceptions);
}
/**
* Return the exception from the last invocation (also exposed as a cause).
* @since 7.0.2
*/
@Override
public Throwable getLastException() {
return getCause();
}
} }

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

@ -33,6 +33,8 @@ import org.jspecify.annotations.Nullable;
*/ */
public interface RetryListener { public interface RetryListener {
// Interception callbacks for retry attempts (not covering the initial invocation)
/** /**
* Called before every retry attempt. * Called before every retry attempt.
* @param retryPolicy the {@link RetryPolicy} * @param retryPolicy the {@link RetryPolicy}
@ -59,6 +61,27 @@ public interface RetryListener {
default void onRetryFailure(RetryPolicy retryPolicy, Retryable<?> retryable, Throwable throwable) { default void onRetryFailure(RetryPolicy retryPolicy, Retryable<?> retryable, Throwable throwable) {
} }
// Execution callbacks for all invocation attempts and terminal scenarios
/**
* Called after every attempt, including the initial invocation.
* <p>The success of the attempt can be checked via {@link RetryState#isSuccessful()};
* if not successful, the current exception can be introspected via
* {@link RetryState#getLastException()}.
* @param retryPolicy the {@link RetryPolicy}
* @param retryable the {@link Retryable} operation
* @param retryState the current state of retry processing
* (this is a live instance reflecting the current state; not intended to be stored)
* @since 7.0.2
* @see RetryTemplate#execute(Retryable)
* @see RetryState#isSuccessful()
* @see RetryState#getLastException()
* @see RetryState#getRetryCount()
*/
default void onRetryableExecution(RetryPolicy retryPolicy, Retryable<?> retryable, RetryState retryState) {
}
/** /**
* Called if the {@link RetryPolicy} is exhausted. * Called if the {@link RetryPolicy} is exhausted.
* @param retryPolicy the {@code RetryPolicy} * @param retryPolicy the {@code RetryPolicy}
@ -66,8 +89,7 @@ public interface RetryListener {
* @param exception the resulting {@link RetryException}, with the last * @param exception the resulting {@link RetryException}, with the last
* exception thrown by the {@code Retryable} operation as the cause and any * exception thrown by the {@code Retryable} operation as the cause and any
* exceptions from previous attempts as suppressed exceptions * exceptions from previous attempts as suppressed exceptions
* @see RetryException#getCause() * @see RetryException#getExceptions()
* @see RetryException#getSuppressed()
* @see RetryException#getRetryCount() * @see RetryException#getRetryCount()
*/ */
default void onRetryPolicyExhaustion(RetryPolicy retryPolicy, Retryable<?> retryable, RetryException exception) { default void onRetryPolicyExhaustion(RetryPolicy retryPolicy, Retryable<?> retryable, RetryException exception) {
@ -80,8 +102,7 @@ public interface RetryListener {
* @param exception the resulting {@link RetryException}, with an * @param exception the resulting {@link RetryException}, with an
* {@link InterruptedException} as the cause and any exceptions from previous * {@link InterruptedException} as the cause and any exceptions from previous
* invocations of the {@code Retryable} operation as suppressed exceptions * invocations of the {@code Retryable} operation as suppressed exceptions
* @see RetryException#getCause() * @see RetryException#getExceptions()
* @see RetryException#getSuppressed()
* @see RetryException#getRetryCount() * @see RetryException#getRetryCount()
*/ */
default void onRetryPolicyInterruption(RetryPolicy retryPolicy, Retryable<?> retryable, RetryException exception) { default void onRetryPolicyInterruption(RetryPolicy retryPolicy, Retryable<?> retryable, RetryException exception) {
@ -96,8 +117,7 @@ public interface RetryListener {
* exception thrown by the {@code Retryable} operation as the cause and any * exception thrown by the {@code Retryable} operation as the cause and any
* exceptions from previous attempts as suppressed exceptions * exceptions from previous attempts as suppressed exceptions
* @since 7.0.2 * @since 7.0.2
* @see RetryException#getCause() * @see RetryException#getExceptions()
* @see RetryException#getSuppressed()
* @see RetryException#getRetryCount() * @see RetryException#getRetryCount()
*/ */
default void onRetryPolicyTimeout(RetryPolicy retryPolicy, Retryable<?> retryable, RetryException exception) { default void onRetryPolicyTimeout(RetryPolicy retryPolicy, Retryable<?> retryable, RetryException exception) {

69
spring-core/src/main/java/org/springframework/core/retry/RetryState.java

@ -0,0 +1,69 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.core.retry;
import java.util.List;
/**
* A representation of the current retry state, including the
* current retry count and the exceptions accumulated so far.
*
* <p>Used as a parameter for {@link RetryListener#onRetryableExecution}.
* Implemented by {@link RetryException} as well, exposing the final outcome in
* the terminal listener methods {@link RetryListener#onRetryPolicyExhaustion},
* {@link RetryListener#onRetryPolicyInterruption} and
* {@link RetryListener#onRetryPolicyTimeout}.
*
* @author Juergen Hoeller
* @since 7.0.2
*/
public interface RetryState {
/**
* Return the current retry count: 0 indicates the initial invocation,
* 1 the first retry attempt, etc.
* <p>This may indicate the current attempt or the final number of
* retry attempts, depending on the time of the method call.
*/
int getRetryCount();
/**
* Return the invocation exceptions accumulated so far,
* in the order of occurrence.
*/
List<Throwable> getExceptions();
/**
* Return the recorded exception from the last invocation.
* @throws IllegalStateException if no exception has been recorded
*/
default Throwable getLastException() {
List<Throwable> exceptions = getExceptions();
if (exceptions.isEmpty()) {
throw new IllegalStateException("No exception recorded");
}
return exceptions.get(exceptions.size() - 1);
}
/**
* Indicate whether a successful invocation has been accomplished.
*/
default boolean isSuccessful() {
return getRetryCount() >= getExceptions().size();
}
}

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

@ -16,9 +16,9 @@
package org.springframework.core.retry; package org.springframework.core.retry;
import java.io.Serial; import java.util.ArrayList;
import java.util.ArrayDeque; import java.util.Collections;
import java.util.Deque; import java.util.List;
import org.jspecify.annotations.Nullable; import org.jspecify.annotations.Nullable;
@ -135,80 +135,93 @@ public class RetryTemplate implements RetryOperations {
*/ */
@Override @Override
public <R extends @Nullable Object> R execute(Retryable<R> retryable) throws RetryException { public <R extends @Nullable Object> R execute(Retryable<R> retryable) throws RetryException {
String retryableName = retryable.getName();
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
String retryableName = retryable.getName();
MutableRetryState retryState = new MutableRetryState();
// Initial attempt // Initial attempt
try {
logger.debug(() -> "Preparing to execute retryable operation '%s'".formatted(retryableName)); logger.debug(() -> "Preparing to execute retryable operation '%s'".formatted(retryableName));
R result = retryable.execute(); R result;
logger.debug(() -> "Retryable operation '%s' completed successfully".formatted(retryableName)); try {
return result; result = retryable.execute();
} }
catch (Throwable initialException) { catch (Throwable initialException) {
logger.debug(initialException, logger.debug(initialException,
() -> "Execution of retryable operation '%s' failed; initiating the retry process" () -> "Execution of retryable operation '%s' failed; initiating the retry process"
.formatted(retryableName)); .formatted(retryableName));
retryState.addException(initialException);
this.retryListener.onRetryableExecution(this.retryPolicy, retryable, retryState);
// Retry process starts here // Retry process starts here
BackOffExecution backOffExecution = this.retryPolicy.getBackOff().start(); BackOffExecution backOffExecution = this.retryPolicy.getBackOff().start();
Deque<Throwable> exceptions = new ArrayDeque<>(4);
exceptions.add(initialException);
Throwable lastException = initialException; Throwable lastException = initialException;
long timeout = this.retryPolicy.getTimeout().toMillis(); long timeout = this.retryPolicy.getTimeout().toMillis();
while (this.retryPolicy.shouldRetry(lastException)) { while (this.retryPolicy.shouldRetry(lastException)) {
checkIfTimeoutExceeded(timeout, startTime, 0, retryable, exceptions); checkIfTimeoutExceeded(timeout, startTime, 0, retryable, retryState);
try { try {
long sleepTime = backOffExecution.nextBackOff(); long sleepTime = backOffExecution.nextBackOff();
if (sleepTime == BackOffExecution.STOP) { if (sleepTime == BackOffExecution.STOP) {
break; break;
} }
checkIfTimeoutExceeded(timeout, startTime, sleepTime, retryable, exceptions); checkIfTimeoutExceeded(timeout, startTime, sleepTime, retryable, retryState);
logger.debug(() -> "Backing off for %dms after retryable operation '%s'" logger.debug(() -> "Backing off for %dms after retryable operation '%s'"
.formatted(sleepTime, retryableName)); .formatted(sleepTime, retryableName));
Thread.sleep(sleepTime); Thread.sleep(sleepTime);
} }
catch (InterruptedException interruptedException) { catch (InterruptedException interruptedException) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
RetryException retryException = new RetryInterruptedException( RetryException retryException = new RetryException(
"Unable to back off for retryable operation '%s'".formatted(retryableName), "Interrupted during back-off for retryable operation '%s'".formatted(retryableName),
interruptedException); retryState);
exceptions.forEach(retryException::addSuppressed);
this.retryListener.onRetryPolicyInterruption(this.retryPolicy, retryable, retryException); this.retryListener.onRetryPolicyInterruption(this.retryPolicy, retryable, retryException);
throw retryException; throw retryException;
} }
logger.debug(() -> "Preparing to retry operation '%s'".formatted(retryableName)); logger.debug(() -> "Preparing to retry operation '%s'".formatted(retryableName));
try { retryState.increaseRetryCount();
this.retryListener.beforeRetry(this.retryPolicy, retryable); this.retryListener.beforeRetry(this.retryPolicy, retryable);
R result = retryable.execute(); try {
this.retryListener.onRetrySuccess(this.retryPolicy, retryable, result); result = retryable.execute();
logger.debug(() -> "Retryable operation '%s' completed successfully after retry"
.formatted(retryableName));
return result;
} }
catch (Throwable currentException) { catch (Throwable currentException) {
logger.debug(currentException, () -> "Retry attempt for operation '%s' failed due to '%s'" logger.debug(currentException, () -> "Retry attempt for operation '%s' failed due to '%s'"
.formatted(retryableName, currentException)); .formatted(retryableName, currentException));
retryState.addException(currentException);
this.retryListener.onRetryFailure(this.retryPolicy, retryable, currentException); this.retryListener.onRetryFailure(this.retryPolicy, retryable, currentException);
exceptions.add(currentException); this.retryListener.onRetryableExecution(this.retryPolicy, retryable, retryState);
lastException = currentException; lastException = currentException;
continue;
} }
// Did not enter catch block above -> retry success.
logger.debug(() -> "Retryable operation '%s' completed successfully after retry"
.formatted(retryableName));
this.retryListener.onRetrySuccess(this.retryPolicy, retryable, result);
this.retryListener.onRetryableExecution(this.retryPolicy, retryable, retryState);
return result;
} }
// The RetryPolicy has exhausted at this point, so we throw a RetryException with the // The RetryPolicy has exhausted at this point, so we throw a RetryException with the
// last exception as the cause and remaining exceptions as suppressed exceptions. // last exception as the cause and remaining exceptions as suppressed exceptions.
RetryException retryException = new RetryException( RetryException retryException = new RetryException(
"Retry policy for operation '%s' exhausted; aborting execution".formatted(retryableName), "Retry policy for operation '%s' exhausted; aborting execution".formatted(retryableName),
exceptions.removeLast()); retryState);
exceptions.forEach(retryException::addSuppressed);
this.retryListener.onRetryPolicyExhaustion(this.retryPolicy, retryable, retryException); this.retryListener.onRetryPolicyExhaustion(this.retryPolicy, retryable, retryException);
throw retryException; throw retryException;
} }
// Never entered initial catch block -> initial success.
logger.debug(() -> "Retryable operation '%s' completed successfully".formatted(retryableName));
this.retryListener.onRetryableExecution(this.retryPolicy, retryable, retryState);
return result;
} }
private void checkIfTimeoutExceeded(long timeout, long startTime, long sleepTime, Retryable<?> retryable, private void checkIfTimeoutExceeded(long timeout, long startTime, long sleepTime, Retryable<?> retryable,
Deque<Throwable> exceptions) throws RetryException { RetryState retryState) throws RetryException {
if (timeout != 0) { if (timeout > 0) {
// If sleepTime > 0, we are predicting what the effective elapsed time // If sleepTime > 0, we are predicting what the effective elapsed time
// would be if we were to sleep for sleepTime milliseconds. // would be if we were to sleep for sleepTime milliseconds.
long elapsedTime = System.currentTimeMillis() + sleepTime - startTime; long elapsedTime = System.currentTimeMillis() + sleepTime - startTime;
@ -219,8 +232,7 @@ public class RetryTemplate implements RetryOperations {
.formatted(retryable.getName(), timeout, sleepTime) : .formatted(retryable.getName(), timeout, sleepTime) :
"Retry policy for operation '%s' exceeded timeout (%dms); aborting execution" "Retry policy for operation '%s' exceeded timeout (%dms); aborting execution"
.formatted(retryable.getName(), timeout)); .formatted(retryable.getName(), timeout));
RetryException retryException = new RetryException(message, exceptions.removeLast()); RetryException retryException = new RetryException(message, retryState);
exceptions.forEach(retryException::addSuppressed);
this.retryListener.onRetryPolicyTimeout(this.retryPolicy, retryable, retryException); this.retryListener.onRetryPolicyTimeout(this.retryPolicy, retryable, retryException);
throw retryException; throw retryException;
} }
@ -228,19 +240,33 @@ public class RetryTemplate implements RetryOperations {
} }
private static class RetryInterruptedException extends RetryException { private static class MutableRetryState implements RetryState {
@Serial private int retryCount;
private static final long serialVersionUID = 1L;
private final List<Throwable> exceptions = new ArrayList<>(4);
RetryInterruptedException(String message, InterruptedException cause) { public void increaseRetryCount(){
super(message, cause); this.retryCount++;
} }
@Override @Override
public int getRetryCount() { public int getRetryCount() {
return (getSuppressed().length - 1); return this.retryCount;
}
public void addException(Throwable exception) {
this.exceptions.add(exception);
}
@Override
public List<Throwable> getExceptions() {
return Collections.unmodifiableList(this.exceptions);
}
@Override
public String toString() {
return "RetryState: retryCount=" + this.retryCount + ", exceptions=" + this.exceptions;
} }
} }

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

@ -24,6 +24,7 @@ import org.jspecify.annotations.Nullable;
import org.springframework.core.retry.RetryException; import org.springframework.core.retry.RetryException;
import org.springframework.core.retry.RetryListener; import org.springframework.core.retry.RetryListener;
import org.springframework.core.retry.RetryPolicy; import org.springframework.core.retry.RetryPolicy;
import org.springframework.core.retry.RetryState;
import org.springframework.core.retry.Retryable; import org.springframework.core.retry.Retryable;
import org.springframework.util.Assert; import org.springframework.util.Assert;
@ -85,6 +86,11 @@ public class CompositeRetryListener implements RetryListener {
this.listeners.forEach(listener -> listener.onRetryFailure(retryPolicy, retryable, throwable)); this.listeners.forEach(listener -> listener.onRetryFailure(retryPolicy, retryable, throwable));
} }
@Override
public void onRetryableExecution(RetryPolicy retryPolicy, Retryable<?> retryable, RetryState retryState) {
this.listeners.forEach(listener -> listener.onRetryableExecution(retryPolicy, retryable, retryState));
}
@Override @Override
public void onRetryPolicyExhaustion(RetryPolicy retryPolicy, Retryable<?> retryable, RetryException exception) { public void onRetryPolicyExhaustion(RetryPolicy retryPolicy, Retryable<?> retryable, RetryException exception) {
this.listeners.forEach(listener -> listener.onRetryPolicyExhaustion(retryPolicy, retryable, exception)); this.listeners.forEach(listener -> listener.onRetryPolicyExhaustion(retryPolicy, retryable, exception));

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

@ -40,10 +40,10 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.junit.jupiter.params.provider.Arguments.argumentSet; import static org.junit.jupiter.params.provider.Arguments.argumentSet;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.verifyNoMoreInteractions;
/** /**
@ -91,7 +91,9 @@ class RetryTemplateTests {
assertThat(invocationCount).hasValue(1); assertThat(invocationCount).hasValue(1);
// RetryListener interactions: // RetryListener interactions:
verifyNoInteractions(retryListener); inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable),
argThat(state -> state.isSuccessful() && state.getRetryCount() == 0));
verifyNoMoreInteractions(retryListener);
} }
@Test @Test
@ -110,6 +112,10 @@ class RetryTemplateTests {
.withCause(exception) .withCause(exception)
.satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty()) .satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty())
.satisfies(throwable -> assertThat(throwable.getRetryCount()).isZero()) .satisfies(throwable -> assertThat(throwable.getRetryCount()).isZero())
.satisfies(throwable -> assertThat(throwable.getExceptions()).containsExactly(exception))
.satisfies(throwable -> assertThat(throwable.getLastException()).isSameAs(exception))
.satisfies(throwable -> inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable),
argThat(state -> !state.isSuccessful() && state.getRetryCount() == 0)))
.satisfies(throwable -> inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, throwable)); .satisfies(throwable -> inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, throwable));
verifyNoMoreInteractions(retryListener); verifyNoMoreInteractions(retryListener);
@ -133,6 +139,10 @@ class RetryTemplateTests {
.withCause(exception) .withCause(exception)
.satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty()) .satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty())
.satisfies(throwable -> assertThat(throwable.getRetryCount()).isZero()) .satisfies(throwable -> assertThat(throwable.getRetryCount()).isZero())
.satisfies(throwable -> assertThat(throwable.getExceptions()).containsExactly(exception))
.satisfies(throwable -> assertThat(throwable.getLastException()).isSameAs(exception))
.satisfies(throwable -> inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable),
argThat(state -> !state.isSuccessful() && state.getRetryCount() == 0)))
.satisfies(throwable -> inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, throwable)); .satisfies(throwable -> inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, throwable));
verifyNoMoreInteractions(retryListener); verifyNoMoreInteractions(retryListener);
@ -155,6 +165,10 @@ class RetryTemplateTests {
.withCause(exception) .withCause(exception)
.satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty()) .satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty())
.satisfies(throwable -> assertThat(throwable.getRetryCount()).isZero()) .satisfies(throwable -> assertThat(throwable.getRetryCount()).isZero())
.satisfies(throwable -> assertThat(throwable.getExceptions()).containsExactly(exception))
.satisfies(throwable -> assertThat(throwable.getLastException()).isSameAs(exception))
.satisfies(throwable -> inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable),
argThat(state -> !state.isSuccessful() && state.getRetryCount() == 0)))
.satisfies(throwable -> inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, throwable)); .satisfies(throwable -> inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, throwable));
verifyNoMoreInteractions(retryListener); verifyNoMoreInteractions(retryListener);
@ -175,10 +189,14 @@ class RetryTemplateTests {
assertThat(invocationCount).hasValue(3); assertThat(invocationCount).hasValue(3);
// RetryListener interactions: // RetryListener interactions:
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable), any(RetryState.class));
inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable);
inOrder.verify(retryListener).onRetryFailure(retryPolicy, retryable, new CustomException("Boom 2")); inOrder.verify(retryListener).onRetryFailure(retryPolicy, retryable, new CustomException("Boom 2"));
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable), any(RetryState.class));
inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable);
inOrder.verify(retryListener).onRetrySuccess(retryPolicy, retryable, "finally succeeded"); inOrder.verify(retryListener).onRetrySuccess(retryPolicy, retryable, "finally succeeded");
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable),
argThat(state -> state.isSuccessful() && state.getRetryCount() == 2));
verifyNoMoreInteractions(retryListener); verifyNoMoreInteractions(retryListener);
} }
@ -191,7 +209,6 @@ class RetryTemplateTests {
public String execute() { public String execute() {
throw new CustomException("Boom " + invocationCount.incrementAndGet()); throw new CustomException("Boom " + invocationCount.incrementAndGet());
} }
@Override @Override
public String getName() { public String getName() {
return "test"; return "test";
@ -206,10 +223,13 @@ class RetryTemplateTests {
.satisfies(throwable -> { .satisfies(throwable -> {
var counter = new AtomicInteger(1); var counter = new AtomicInteger(1);
repeat(3, () -> { repeat(3, () -> {
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable), any(RetryState.class));
inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable);
inOrder.verify(retryListener).onRetryFailure(retryPolicy, retryable, inOrder.verify(retryListener).onRetryFailure(retryPolicy, retryable,
new CustomException("Boom " + counter.incrementAndGet())); new CustomException("Boom " + counter.incrementAndGet()));
}); });
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable),
argThat(state -> !state.isSuccessful() && state.getRetryCount() == 3));
inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, throwable); inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, throwable);
}); });
// 4 = 1 initial invocation + 3 retry attempts // 4 = 1 initial invocation + 3 retry attempts
@ -237,10 +257,12 @@ class RetryTemplateTests {
assertThatExceptionOfType(RetryException.class) assertThatExceptionOfType(RetryException.class)
.isThrownBy(() -> retryTemplate.execute(retryable)) .isThrownBy(() -> retryTemplate.execute(retryable))
.withMessageMatching("Unable to back off for retryable operation '.+?'") .withMessageMatching("Interrupted during back-off for retryable operation '.+?'")
.withCause(interruptedException) .withCause(exception)
.satisfies(throwable -> assertThat(throwable.getSuppressed()).containsExactly(exception)) .satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty())
.satisfies(throwable -> assertThat(throwable.getRetryCount()).isZero()) .satisfies(throwable -> assertThat(throwable.getRetryCount()).isZero())
.satisfies(throwable -> inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable),
argThat(state -> !state.isSuccessful() && state.getRetryCount() == 0)))
.satisfies(throwable -> inOrder.verify(retryListener).onRetryPolicyInterruption(retryPolicy, retryable, throwable)); .satisfies(throwable -> inOrder.verify(retryListener).onRetryPolicyInterruption(retryPolicy, retryable, throwable));
verifyNoMoreInteractions(retryListener); verifyNoMoreInteractions(retryListener);
@ -257,7 +279,6 @@ class RetryTemplateTests {
invocationCount.incrementAndGet(); invocationCount.incrementAndGet();
throw exception; throw exception;
} }
@Override @Override
public String getName() { public String getName() {
return "always fails"; return "always fails";
@ -280,9 +301,12 @@ class RetryTemplateTests {
.withCause(exception) .withCause(exception)
.satisfies(throwable -> { .satisfies(throwable -> {
repeat(5, () -> { repeat(5, () -> {
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable), any(RetryState.class));
inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable);
inOrder.verify(retryListener).onRetryFailure(retryPolicy, retryable, exception); inOrder.verify(retryListener).onRetryFailure(retryPolicy, retryable, exception);
}); });
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable),
argThat(state -> !state.isSuccessful() && state.getRetryCount() == 5));
inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, throwable); inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, throwable);
}); });
// 6 = 1 initial invocation + 5 retry attempts // 6 = 1 initial invocation + 5 retry attempts
@ -305,7 +329,6 @@ class RetryTemplateTests {
default -> "success"; default -> "success";
}; };
} }
@Override @Override
public String getName() { public String getName() {
return "test"; return "test";
@ -332,9 +355,12 @@ class RetryTemplateTests {
.satisfies(throwable -> assertThat(throwable.getRetryCount()).isEqualTo(2)) .satisfies(throwable -> assertThat(throwable.getRetryCount()).isEqualTo(2))
.satisfies(throwable -> { .satisfies(throwable -> {
repeat(2, () -> { repeat(2, () -> {
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable), any(RetryState.class));
inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable);
inOrder.verify(retryListener).onRetryFailure(eq(retryPolicy), eq(retryable), any(Exception.class)); inOrder.verify(retryListener).onRetryFailure(eq(retryPolicy), eq(retryable), any(Exception.class));
}); });
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable),
argThat(state -> !state.isSuccessful() && state.getRetryCount() == 2));
inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, throwable); inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, throwable);
}); });
// 3 = 1 initial invocation + 2 retry attempts // 3 = 1 initial invocation + 2 retry attempts
@ -376,7 +402,6 @@ class RetryTemplateTests {
default -> "success"; default -> "success";
}; };
} }
@Override @Override
public String getName() { public String getName() {
return "test"; return "test";
@ -395,10 +420,14 @@ class RetryTemplateTests {
)) ))
.satisfies(throwable -> assertThat(throwable.getRetryCount()).isEqualTo(2)) .satisfies(throwable -> assertThat(throwable.getRetryCount()).isEqualTo(2))
.satisfies(throwable -> { .satisfies(throwable -> {
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable), any(RetryState.class));
inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable);
inOrder.verify(retryListener).onRetryFailure(eq(retryPolicy), eq(retryable), any(RuntimeException.class)); inOrder.verify(retryListener).onRetryFailure(eq(retryPolicy), eq(retryable), any(RuntimeException.class));
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable), any(RetryState.class));
inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable);
inOrder.verify(retryListener).onRetryFailure(eq(retryPolicy), eq(retryable), any(CustomFileNotFoundException.class)); inOrder.verify(retryListener).onRetryFailure(eq(retryPolicy), eq(retryable), any(CustomFileNotFoundException.class));
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable),
argThat(state -> !state.isSuccessful() && state.getRetryCount() == 2));
inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, throwable); inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, throwable);
}); });
// 3 = 1 initial invocation + 2 retry attempts // 3 = 1 initial invocation + 2 retry attempts
@ -429,7 +458,9 @@ class RetryTemplateTests {
assertThat(invocationCount).hasValue(1); assertThat(invocationCount).hasValue(1);
// RetryListener interactions: // RetryListener interactions:
verifyNoInteractions(retryListener); inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable),
argThat(state -> state.isSuccessful() && state.getRetryCount() == 0));
verifyNoMoreInteractions(retryListener);
} }
@Test @Test
@ -453,13 +484,15 @@ class RetryTemplateTests {
.withCause(exception) .withCause(exception)
.satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty()) .satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty())
.satisfies(throwable -> assertThat(throwable.getRetryCount()).isZero()) .satisfies(throwable -> assertThat(throwable.getRetryCount()).isZero())
.satisfies(throwable -> inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable),
argThat(state -> !state.isSuccessful() && state.getRetryCount() == 0)))
.satisfies(throwable -> inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, throwable)); .satisfies(throwable -> inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, throwable));
verifyNoMoreInteractions(retryListener); verifyNoMoreInteractions(retryListener);
} }
@Test @Test
void retryWithTimeoutExceededAfterInitialFailure() throws Exception { void retryWithTimeoutExceededAfterInitialFailure() {
RetryPolicy retryPolicy = RetryPolicy.builder() RetryPolicy retryPolicy = RetryPolicy.builder()
.timeout(Duration.ofMillis(10)) .timeout(Duration.ofMillis(10))
.delay(Duration.ZERO) .delay(Duration.ZERO)
@ -478,6 +511,8 @@ class RetryTemplateTests {
.isThrownBy(() -> retryTemplate.execute(retryable)) .isThrownBy(() -> retryTemplate.execute(retryable))
.withMessageMatching("Retry policy for operation '.+?' exceeded timeout \\(10ms\\); aborting execution") .withMessageMatching("Retry policy for operation '.+?' exceeded timeout \\(10ms\\); aborting execution")
.withCause(new CustomException("Boom 1")) .withCause(new CustomException("Boom 1"))
.satisfies(throwable -> inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable),
argThat(state -> !state.isSuccessful() && state.getRetryCount() == 0)))
.satisfies(throwable -> inOrder.verify(retryListener).onRetryPolicyTimeout( .satisfies(throwable -> inOrder.verify(retryListener).onRetryPolicyTimeout(
eq(retryPolicy), eq(retryable), eq(throwable))); eq(retryPolicy), eq(retryable), eq(throwable)));
assertThat(invocationCount).hasValue(1); assertThat(invocationCount).hasValue(1);
@ -486,7 +521,7 @@ class RetryTemplateTests {
} }
@Test @Test
void retryWithTimeoutExceededAfterFirstDelayButBeforeFirstRetry() throws Exception { void retryWithTimeoutExceededAfterFirstDelayButBeforeFirstRetry() {
RetryPolicy retryPolicy = RetryPolicy.builder() RetryPolicy retryPolicy = RetryPolicy.builder()
.timeout(Duration.ofMillis(20)) .timeout(Duration.ofMillis(20))
.delay(Duration.ofMillis(100)) // Delay > Timeout .delay(Duration.ofMillis(100)) // Delay > Timeout
@ -507,6 +542,8 @@ class RetryTemplateTests {
due to pending sleep time \\(100ms\\); preemptively aborting execution\ due to pending sleep time \\(100ms\\); preemptively aborting execution\
""") """)
.withCause(new CustomException("Boom 1")) .withCause(new CustomException("Boom 1"))
.satisfies(throwable -> inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable),
argThat(state -> !state.isSuccessful() && state.getRetryCount() == 0)))
.satisfies(throwable -> inOrder.verify(retryListener).onRetryPolicyTimeout( .satisfies(throwable -> inOrder.verify(retryListener).onRetryPolicyTimeout(
eq(retryPolicy), eq(retryable), eq(throwable))); eq(retryPolicy), eq(retryable), eq(throwable)));
assertThat(invocationCount).hasValue(1); assertThat(invocationCount).hasValue(1);
@ -515,7 +552,7 @@ class RetryTemplateTests {
} }
@Test @Test
void retryWithTimeoutExceededAfterFirstRetry() throws Exception { void retryWithTimeoutExceededAfterFirstRetry() {
RetryPolicy retryPolicy = RetryPolicy.builder() RetryPolicy retryPolicy = RetryPolicy.builder()
.timeout(Duration.ofMillis(20)) .timeout(Duration.ofMillis(20))
.delay(Duration.ZERO) .delay(Duration.ZERO)
@ -538,9 +575,11 @@ class RetryTemplateTests {
.withMessageMatching("Retry policy for operation '.+?' exceeded timeout \\(20ms\\); aborting execution") .withMessageMatching("Retry policy for operation '.+?' exceeded timeout \\(20ms\\); aborting execution")
.withCause(new CustomException("Boom 2")) .withCause(new CustomException("Boom 2"))
.satisfies(throwable -> { .satisfies(throwable -> {
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable), any());
inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable);
inOrder.verify(retryListener).onRetryFailure(retryPolicy, retryable, new CustomException("Boom 2")); inOrder.verify(retryListener).onRetryFailure(retryPolicy, retryable, new CustomException("Boom 2"));
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable),
argThat(state -> !state.isSuccessful() && state.getRetryCount() == 1));
inOrder.verify(retryListener).onRetryPolicyTimeout( inOrder.verify(retryListener).onRetryPolicyTimeout(
eq(retryPolicy), eq(retryable), eq(throwable)); eq(retryPolicy), eq(retryable), eq(throwable));
}); });
@ -550,7 +589,7 @@ class RetryTemplateTests {
} }
@Test @Test
void retryWithTimeoutExceededAfterSecondRetry() throws Exception { void retryWithTimeoutExceededAfterSecondRetry() {
RetryPolicy retryPolicy = RetryPolicy.builder() RetryPolicy retryPolicy = RetryPolicy.builder()
.timeout(Duration.ofMillis(20)) .timeout(Duration.ofMillis(20))
.delay(Duration.ZERO) .delay(Duration.ZERO)
@ -575,10 +614,13 @@ class RetryTemplateTests {
.satisfies(throwable -> { .satisfies(throwable -> {
var counter = new AtomicInteger(1); var counter = new AtomicInteger(1);
repeat(2, () -> { repeat(2, () -> {
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable), any());
inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable);
inOrder.verify(retryListener).onRetryFailure(retryPolicy, retryable, inOrder.verify(retryListener).onRetryFailure(retryPolicy, retryable,
new CustomException("Boom " + counter.incrementAndGet())); new CustomException("Boom " + counter.incrementAndGet()));
}); });
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable),
argThat(state -> !state.isSuccessful() && state.getRetryCount() == 2));
inOrder.verify(retryListener).onRetryPolicyTimeout( inOrder.verify(retryListener).onRetryPolicyTimeout(
eq(retryPolicy), eq(retryable), eq(throwable)); eq(retryPolicy), eq(retryable), eq(throwable));
}); });
@ -586,7 +628,6 @@ class RetryTemplateTests {
verifyNoMoreInteractions(retryListener); verifyNoMoreInteractions(retryListener);
} }
} }

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

@ -82,6 +82,16 @@ class CompositeRetryListenerTests {
verify(listener3).onRetryFailure(retryPolicy, retryable, exception); verify(listener3).onRetryFailure(retryPolicy, retryable, exception);
} }
@Test
void onRetryableExecution() {
RetryException exception = new RetryException("", new Exception());
compositeRetryListener.onRetryableExecution(retryPolicy, retryable, exception);
verify(listener1).onRetryableExecution(retryPolicy, retryable, exception);
verify(listener2).onRetryableExecution(retryPolicy, retryable, exception);
verify(listener3).onRetryableExecution(retryPolicy, retryable, exception);
}
@Test @Test
void onRetryPolicyExhaustion() { void onRetryPolicyExhaustion() {
RetryException exception = new RetryException("", new Exception()); RetryException exception = new RetryException("", new Exception());

Loading…
Cancel
Save