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 @@ @@ -17,6 +17,9 @@
package org.springframework.core.retry;
import java.io.Serial;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
/**
@ -27,13 +30,8 @@ import java.util.Objects; @@ -27,13 +30,8 @@ import java.util.Objects;
* any exceptions from previous attempts as {@linkplain #getSuppressed() suppressed
* exceptions}.
*
* <p>However, if an {@link InterruptedException} is encountered while
* {@linkplain Thread#sleep(long) sleeping} for the current
* {@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}.
* <p>Implements the {@link RetryState} interface for exposing the final outcome,
* as a parameter of the terminal listener methods on {@link RetryListener}.
*
* @author Mahmoud Ben Hassine
* @author Juergen Hoeller
@ -41,7 +39,7 @@ import java.util.Objects; @@ -41,7 +39,7 @@ import java.util.Objects;
* @since 7.0
* @see RetryOperations
*/
public class RetryException extends Exception {
public class RetryException extends Exception implements RetryState {
@Serial
private static final long serialVersionUID = 1L;
@ -50,14 +48,26 @@ public class RetryException extends Exception { @@ -50,14 +48,26 @@ public class RetryException extends Exception {
/**
* Create a new {@code RetryException} for the supplied message and cause.
* @param message the detail message
* @param cause the last exception thrown by the {@link Retryable} operation,
* or an {@link InterruptedException} thrown while sleeping for the current
* {@code BackOff} duration
* @param cause the last exception thrown by the {@link Retryable} operation
*/
public RetryException(String message, Throwable cause) {
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
@ -73,8 +83,31 @@ public class RetryException extends Exception { @@ -73,8 +83,31 @@ public class RetryException extends Exception {
* Return the number of retry attempts, or 0 if no retry has been attempted
* after the initial invocation at all.
*/
@Override
public int getRetryCount() {
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; @@ -33,6 +33,8 @@ import org.jspecify.annotations.Nullable;
*/
public interface RetryListener {
// Interception callbacks for retry attempts (not covering the initial invocation)
/**
* Called before every retry attempt.
* @param retryPolicy the {@link RetryPolicy}
@ -59,6 +61,27 @@ public interface RetryListener { @@ -59,6 +61,27 @@ public interface RetryListener {
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.
* @param retryPolicy the {@code RetryPolicy}
@ -66,8 +89,7 @@ public interface RetryListener { @@ -66,8 +89,7 @@ public interface RetryListener {
* @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
* @see RetryException#getCause()
* @see RetryException#getSuppressed()
* @see RetryException#getExceptions()
* @see RetryException#getRetryCount()
*/
default void onRetryPolicyExhaustion(RetryPolicy retryPolicy, Retryable<?> retryable, RetryException exception) {
@ -80,8 +102,7 @@ public interface RetryListener { @@ -80,8 +102,7 @@ public interface RetryListener {
* @param exception the resulting {@link RetryException}, with an
* {@link InterruptedException} as the cause and any exceptions from previous
* invocations of the {@code Retryable} operation as suppressed exceptions
* @see RetryException#getCause()
* @see RetryException#getSuppressed()
* @see RetryException#getExceptions()
* @see RetryException#getRetryCount()
*/
default void onRetryPolicyInterruption(RetryPolicy retryPolicy, Retryable<?> retryable, RetryException exception) {
@ -96,8 +117,7 @@ public interface RetryListener { @@ -96,8 +117,7 @@ public interface RetryListener {
* 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#getExceptions()
* @see RetryException#getRetryCount()
*/
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 @@ @@ -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 @@ @@ -16,9 +16,9 @@
package org.springframework.core.retry;
import java.io.Serial;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.jspecify.annotations.Nullable;
@ -135,80 +135,93 @@ public class RetryTemplate implements RetryOperations { @@ -135,80 +135,93 @@ public class RetryTemplate implements RetryOperations {
*/
@Override
public <R extends @Nullable Object> R execute(Retryable<R> retryable) throws RetryException {
String retryableName = retryable.getName();
long startTime = System.currentTimeMillis();
String retryableName = retryable.getName();
MutableRetryState retryState = new MutableRetryState();
// Initial attempt
logger.debug(() -> "Preparing to execute retryable operation '%s'".formatted(retryableName));
R result;
try {
logger.debug(() -> "Preparing to execute retryable operation '%s'".formatted(retryableName));
R result = retryable.execute();
logger.debug(() -> "Retryable operation '%s' completed successfully".formatted(retryableName));
return result;
result = retryable.execute();
}
catch (Throwable initialException) {
logger.debug(initialException,
() -> "Execution of retryable operation '%s' failed; initiating the retry process"
.formatted(retryableName));
retryState.addException(initialException);
this.retryListener.onRetryableExecution(this.retryPolicy, retryable, retryState);
// Retry process starts here
BackOffExecution backOffExecution = this.retryPolicy.getBackOff().start();
Deque<Throwable> exceptions = new ArrayDeque<>(4);
exceptions.add(initialException);
Throwable lastException = initialException;
long timeout = this.retryPolicy.getTimeout().toMillis();
while (this.retryPolicy.shouldRetry(lastException)) {
checkIfTimeoutExceeded(timeout, startTime, 0, retryable, exceptions);
checkIfTimeoutExceeded(timeout, startTime, 0, retryable, retryState);
try {
long sleepTime = backOffExecution.nextBackOff();
if (sleepTime == BackOffExecution.STOP) {
break;
}
checkIfTimeoutExceeded(timeout, startTime, sleepTime, retryable, exceptions);
checkIfTimeoutExceeded(timeout, startTime, sleepTime, retryable, retryState);
logger.debug(() -> "Backing off for %dms after retryable operation '%s'"
.formatted(sleepTime, retryableName));
Thread.sleep(sleepTime);
}
catch (InterruptedException interruptedException) {
Thread.currentThread().interrupt();
RetryException retryException = new RetryInterruptedException(
"Unable to back off for retryable operation '%s'".formatted(retryableName),
interruptedException);
exceptions.forEach(retryException::addSuppressed);
RetryException retryException = new RetryException(
"Interrupted during back-off for retryable operation '%s'".formatted(retryableName),
retryState);
this.retryListener.onRetryPolicyInterruption(this.retryPolicy, retryable, retryException);
throw retryException;
}
logger.debug(() -> "Preparing to retry operation '%s'".formatted(retryableName));
retryState.increaseRetryCount();
this.retryListener.beforeRetry(this.retryPolicy, retryable);
try {
this.retryListener.beforeRetry(this.retryPolicy, retryable);
R result = retryable.execute();
this.retryListener.onRetrySuccess(this.retryPolicy, retryable, result);
logger.debug(() -> "Retryable operation '%s' completed successfully after retry"
.formatted(retryableName));
return result;
result = retryable.execute();
}
catch (Throwable currentException) {
logger.debug(currentException, () -> "Retry attempt for operation '%s' failed due to '%s'"
.formatted(retryableName, currentException));
retryState.addException(currentException);
this.retryListener.onRetryFailure(this.retryPolicy, retryable, currentException);
exceptions.add(currentException);
this.retryListener.onRetryableExecution(this.retryPolicy, retryable, retryState);
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
// last exception as the cause and remaining exceptions as suppressed exceptions.
RetryException retryException = new RetryException(
"Retry policy for operation '%s' exhausted; aborting execution".formatted(retryableName),
exceptions.removeLast());
exceptions.forEach(retryException::addSuppressed);
retryState);
this.retryListener.onRetryPolicyExhaustion(this.retryPolicy, retryable, 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,
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
// would be if we were to sleep for sleepTime milliseconds.
long elapsedTime = System.currentTimeMillis() + sleepTime - startTime;
@ -219,8 +232,7 @@ public class RetryTemplate implements RetryOperations { @@ -219,8 +232,7 @@ public class RetryTemplate implements RetryOperations {
.formatted(retryable.getName(), timeout, sleepTime) :
"Retry policy for operation '%s' exceeded timeout (%dms); aborting execution"
.formatted(retryable.getName(), timeout));
RetryException retryException = new RetryException(message, exceptions.removeLast());
exceptions.forEach(retryException::addSuppressed);
RetryException retryException = new RetryException(message, retryState);
this.retryListener.onRetryPolicyTimeout(this.retryPolicy, retryable, retryException);
throw retryException;
}
@ -228,19 +240,33 @@ public class RetryTemplate implements RetryOperations { @@ -228,19 +240,33 @@ public class RetryTemplate implements RetryOperations {
}
private static class RetryInterruptedException extends RetryException {
private static class MutableRetryState implements RetryState {
@Serial
private static final long serialVersionUID = 1L;
private int retryCount;
private final List<Throwable> exceptions = new ArrayList<>(4);
RetryInterruptedException(String message, InterruptedException cause) {
super(message, cause);
public void increaseRetryCount(){
this.retryCount++;
}
@Override
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; @@ -24,6 +24,7 @@ import org.jspecify.annotations.Nullable;
import org.springframework.core.retry.RetryException;
import org.springframework.core.retry.RetryListener;
import org.springframework.core.retry.RetryPolicy;
import org.springframework.core.retry.RetryState;
import org.springframework.core.retry.Retryable;
import org.springframework.util.Assert;
@ -85,6 +86,11 @@ public class CompositeRetryListener implements RetryListener { @@ -85,6 +86,11 @@ public class CompositeRetryListener implements RetryListener {
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
public void onRetryPolicyExhaustion(RetryPolicy retryPolicy, Retryable<?> retryable, RetryException 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; @@ -40,10 +40,10 @@ 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;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.verifyNoMoreInteractions;
/**
@ -91,7 +91,9 @@ class RetryTemplateTests { @@ -91,7 +91,9 @@ class RetryTemplateTests {
assertThat(invocationCount).hasValue(1);
// RetryListener interactions:
verifyNoInteractions(retryListener);
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable),
argThat(state -> state.isSuccessful() && state.getRetryCount() == 0));
verifyNoMoreInteractions(retryListener);
}
@Test
@ -110,6 +112,10 @@ class RetryTemplateTests { @@ -110,6 +112,10 @@ class RetryTemplateTests {
.withCause(exception)
.satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty())
.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));
verifyNoMoreInteractions(retryListener);
@ -133,6 +139,10 @@ class RetryTemplateTests { @@ -133,6 +139,10 @@ class RetryTemplateTests {
.withCause(exception)
.satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty())
.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));
verifyNoMoreInteractions(retryListener);
@ -155,6 +165,10 @@ class RetryTemplateTests { @@ -155,6 +165,10 @@ class RetryTemplateTests {
.withCause(exception)
.satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty())
.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));
verifyNoMoreInteractions(retryListener);
@ -175,10 +189,14 @@ class RetryTemplateTests { @@ -175,10 +189,14 @@ class RetryTemplateTests {
assertThat(invocationCount).hasValue(3);
// RetryListener interactions:
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable), any(RetryState.class));
inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable);
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).onRetrySuccess(retryPolicy, retryable, "finally succeeded");
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable),
argThat(state -> state.isSuccessful() && state.getRetryCount() == 2));
verifyNoMoreInteractions(retryListener);
}
@ -191,7 +209,6 @@ class RetryTemplateTests { @@ -191,7 +209,6 @@ class RetryTemplateTests {
public String execute() {
throw new CustomException("Boom " + invocationCount.incrementAndGet());
}
@Override
public String getName() {
return "test";
@ -206,10 +223,13 @@ class RetryTemplateTests { @@ -206,10 +223,13 @@ class RetryTemplateTests {
.satisfies(throwable -> {
var counter = new AtomicInteger(1);
repeat(3, () -> {
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable), any(RetryState.class));
inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable);
inOrder.verify(retryListener).onRetryFailure(retryPolicy, retryable,
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);
});
// 4 = 1 initial invocation + 3 retry attempts
@ -237,10 +257,12 @@ class RetryTemplateTests { @@ -237,10 +257,12 @@ class RetryTemplateTests {
assertThatExceptionOfType(RetryException.class)
.isThrownBy(() -> retryTemplate.execute(retryable))
.withMessageMatching("Unable to back off for retryable operation '.+?'")
.withCause(interruptedException)
.satisfies(throwable -> assertThat(throwable.getSuppressed()).containsExactly(exception))
.withMessageMatching("Interrupted during back-off for retryable operation '.+?'")
.withCause(exception)
.satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty())
.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));
verifyNoMoreInteractions(retryListener);
@ -257,7 +279,6 @@ class RetryTemplateTests { @@ -257,7 +279,6 @@ class RetryTemplateTests {
invocationCount.incrementAndGet();
throw exception;
}
@Override
public String getName() {
return "always fails";
@ -280,9 +301,12 @@ class RetryTemplateTests { @@ -280,9 +301,12 @@ class RetryTemplateTests {
.withCause(exception)
.satisfies(throwable -> {
repeat(5, () -> {
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable), any(RetryState.class));
inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable);
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);
});
// 6 = 1 initial invocation + 5 retry attempts
@ -305,7 +329,6 @@ class RetryTemplateTests { @@ -305,7 +329,6 @@ class RetryTemplateTests {
default -> "success";
};
}
@Override
public String getName() {
return "test";
@ -332,9 +355,12 @@ class RetryTemplateTests { @@ -332,9 +355,12 @@ class RetryTemplateTests {
.satisfies(throwable -> assertThat(throwable.getRetryCount()).isEqualTo(2))
.satisfies(throwable -> {
repeat(2, () -> {
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable), any(RetryState.class));
inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable);
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);
});
// 3 = 1 initial invocation + 2 retry attempts
@ -376,7 +402,6 @@ class RetryTemplateTests { @@ -376,7 +402,6 @@ class RetryTemplateTests {
default -> "success";
};
}
@Override
public String getName() {
return "test";
@ -395,10 +420,14 @@ class RetryTemplateTests { @@ -395,10 +420,14 @@ class RetryTemplateTests {
))
.satisfies(throwable -> assertThat(throwable.getRetryCount()).isEqualTo(2))
.satisfies(throwable -> {
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable), any(RetryState.class));
inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable);
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).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);
});
// 3 = 1 initial invocation + 2 retry attempts
@ -429,7 +458,9 @@ class RetryTemplateTests { @@ -429,7 +458,9 @@ class RetryTemplateTests {
assertThat(invocationCount).hasValue(1);
// RetryListener interactions:
verifyNoInteractions(retryListener);
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable),
argThat(state -> state.isSuccessful() && state.getRetryCount() == 0));
verifyNoMoreInteractions(retryListener);
}
@Test
@ -453,13 +484,15 @@ class RetryTemplateTests { @@ -453,13 +484,15 @@ class RetryTemplateTests {
.withCause(exception)
.satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty())
.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));
verifyNoMoreInteractions(retryListener);
}
@Test
void retryWithTimeoutExceededAfterInitialFailure() throws Exception {
void retryWithTimeoutExceededAfterInitialFailure() {
RetryPolicy retryPolicy = RetryPolicy.builder()
.timeout(Duration.ofMillis(10))
.delay(Duration.ZERO)
@ -478,6 +511,8 @@ class RetryTemplateTests { @@ -478,6 +511,8 @@ class RetryTemplateTests {
.isThrownBy(() -> retryTemplate.execute(retryable))
.withMessageMatching("Retry policy for operation '.+?' exceeded timeout \\(10ms\\); aborting execution")
.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(
eq(retryPolicy), eq(retryable), eq(throwable)));
assertThat(invocationCount).hasValue(1);
@ -486,7 +521,7 @@ class RetryTemplateTests { @@ -486,7 +521,7 @@ class RetryTemplateTests {
}
@Test
void retryWithTimeoutExceededAfterFirstDelayButBeforeFirstRetry() throws Exception {
void retryWithTimeoutExceededAfterFirstDelayButBeforeFirstRetry() {
RetryPolicy retryPolicy = RetryPolicy.builder()
.timeout(Duration.ofMillis(20))
.delay(Duration.ofMillis(100)) // Delay > Timeout
@ -507,6 +542,8 @@ class RetryTemplateTests { @@ -507,6 +542,8 @@ class RetryTemplateTests {
due to pending sleep time \\(100ms\\); preemptively aborting execution\
""")
.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(
eq(retryPolicy), eq(retryable), eq(throwable)));
assertThat(invocationCount).hasValue(1);
@ -515,7 +552,7 @@ class RetryTemplateTests { @@ -515,7 +552,7 @@ class RetryTemplateTests {
}
@Test
void retryWithTimeoutExceededAfterFirstRetry() throws Exception {
void retryWithTimeoutExceededAfterFirstRetry() {
RetryPolicy retryPolicy = RetryPolicy.builder()
.timeout(Duration.ofMillis(20))
.delay(Duration.ZERO)
@ -538,9 +575,11 @@ class RetryTemplateTests { @@ -538,9 +575,11 @@ class RetryTemplateTests {
.withMessageMatching("Retry policy for operation '.+?' exceeded timeout \\(20ms\\); aborting execution")
.withCause(new CustomException("Boom 2"))
.satisfies(throwable -> {
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable), any());
inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable);
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(
eq(retryPolicy), eq(retryable), eq(throwable));
});
@ -550,7 +589,7 @@ class RetryTemplateTests { @@ -550,7 +589,7 @@ class RetryTemplateTests {
}
@Test
void retryWithTimeoutExceededAfterSecondRetry() throws Exception {
void retryWithTimeoutExceededAfterSecondRetry() {
RetryPolicy retryPolicy = RetryPolicy.builder()
.timeout(Duration.ofMillis(20))
.delay(Duration.ZERO)
@ -575,10 +614,13 @@ class RetryTemplateTests { @@ -575,10 +614,13 @@ class RetryTemplateTests {
.satisfies(throwable -> {
var counter = new AtomicInteger(1);
repeat(2, () -> {
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable), any());
inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable);
inOrder.verify(retryListener).onRetryFailure(retryPolicy, retryable,
new CustomException("Boom " + counter.incrementAndGet()));
});
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), eq(retryable),
argThat(state -> !state.isSuccessful() && state.getRetryCount() == 2));
inOrder.verify(retryListener).onRetryPolicyTimeout(
eq(retryPolicy), eq(retryable), eq(throwable));
});
@ -586,7 +628,6 @@ class RetryTemplateTests { @@ -586,7 +628,6 @@ class RetryTemplateTests {
verifyNoMoreInteractions(retryListener);
}
}

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

@ -82,6 +82,16 @@ class CompositeRetryListenerTests { @@ -82,6 +82,16 @@ class CompositeRetryListenerTests {
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
void onRetryPolicyExhaustion() {
RetryException exception = new RetryException("", new Exception());

Loading…
Cancel
Save