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());