diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryException.java b/spring-core/src/main/java/org/springframework/core/retry/RetryException.java
index dc950b9bccd..794960e9395 100644
--- a/spring-core/src/main/java/org/springframework/core/retry/RetryException.java
+++ b/spring-core/src/main/java/org/springframework/core/retry/RetryException.java
@@ -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;
* any exceptions from previous attempts as {@linkplain #getSuppressed() suppressed
* exceptions}.
*
- *
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}.
+ *
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;
* @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 {
/**
* 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 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 {
* 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 getExceptions() {
+ Throwable[] suppressed = getSuppressed();
+ List 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();
+ }
+
}
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 f237751f63a..b32a8d91c8a 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
@@ -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 {
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.
+ * 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 {
* @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 {
* @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 {
* 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) {
diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryState.java b/spring-core/src/main/java/org/springframework/core/retry/RetryState.java
new file mode 100644
index 00000000000..0a41e32bbb2
--- /dev/null
+++ b/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.
+ *
+ *
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.
+ *
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 getExceptions();
+
+ /**
+ * Return the recorded exception from the last invocation.
+ * @throws IllegalStateException if no exception has been recorded
+ */
+ default Throwable getLastException() {
+ List 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();
+ }
+
+}
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 fa7bcff596f..626341b2bdf 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
@@ -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 {
*/
@Override
public R execute(Retryable 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 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 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 {
.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 {
}
- 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 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 getExceptions() {
+ return Collections.unmodifiableList(this.exceptions);
+ }
+
+ @Override
+ public String toString() {
+ return "RetryState: retryCount=" + this.retryCount + ", exceptions=" + this.exceptions;
}
}
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 5d3c7a62665..33e8693af1d 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
@@ -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 {
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));
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 94409088acf..83e8745a5da 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
@@ -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 {
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 {
.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 {
.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 {
.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 {
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 {
public String execute() {
throw new CustomException("Boom " + invocationCount.incrementAndGet());
}
-
@Override
public String getName() {
return "test";
@@ -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 {
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 {
invocationCount.incrementAndGet();
throw exception;
}
-
@Override
public String getName() {
return "always fails";
@@ -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 {
default -> "success";
};
}
-
@Override
public String getName() {
return "test";
@@ -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 {
default -> "success";
};
}
-
@Override
public String getName() {
return "test";
@@ -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 {
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 {
.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 {
.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 {
}
@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 {
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 {
}
@Test
- void retryWithTimeoutExceededAfterFirstRetry() throws Exception {
+ void retryWithTimeoutExceededAfterFirstRetry() {
RetryPolicy retryPolicy = RetryPolicy.builder()
.timeout(Duration.ofMillis(20))
.delay(Duration.ZERO)
@@ -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 {
}
@Test
- void retryWithTimeoutExceededAfterSecondRetry() throws Exception {
+ void retryWithTimeoutExceededAfterSecondRetry() {
RetryPolicy retryPolicy = RetryPolicy.builder()
.timeout(Duration.ofMillis(20))
.delay(Duration.ZERO)
@@ -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 {
verifyNoMoreInteractions(retryListener);
}
-
}
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 9cd5b3b12a6..08591b6a765 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
@@ -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());