diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryOperations.java b/spring-core/src/main/java/org/springframework/core/retry/RetryOperations.java index c4f0e250177..1548c78bdcc 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/RetryOperations.java +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryOperations.java @@ -16,6 +16,8 @@ package org.springframework.core.retry; +import java.util.function.Supplier; + import org.jspecify.annotations.Nullable; /** @@ -28,6 +30,7 @@ import org.jspecify.annotations.Nullable; * project but redesigned as a minimal core retry feature in the Spring Framework. * * @author Mahmoud Ben Hassine + * @author Juergen Hoeller * @since 7.0 * @see RetryTemplate */ @@ -43,9 +46,24 @@ public interface RetryOperations { * attempts as {@linkplain RetryException#getSuppressed() suppressed exceptions}. * @param retryable the {@code Retryable} to execute and retry if needed * @param the type of the result - * @return the result of the {@code Retryable}, if any - * @throws RetryException if the {@code RetryPolicy} is exhausted + * @return the successful result of the {@code Retryable}, if any + * @throws RetryException if the {@code RetryPolicy} is exhausted. Note that this + * exception represents a failure outcome and is not meant to be propagated; you + * will typically rather rethrow its cause (the last original exception thrown by + * the {@code Retryable} callback) or throw a custom business exception instead. */ R execute(Retryable retryable) throws RetryException; + /** + * Invoke the given {@link Supplier} according to the {@link RetryPolicy}, + * returning a successful result or throwing the last {@code Supplier} exception + * to the caller in case of retry policy exhaustion. + * @param retryable the {@code Supplier} to invoke and retry if needed + * @param the type of the result + * @return the result of the {@code Supplier} + * @throws RuntimeException if thrown by the {@code Supplier} + * @since 7.0.3 + */ + R invoke(Supplier retryable); + } 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 626341b2bdf..11db26d6942 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,11 @@ package org.springframework.core.retry; +import java.lang.reflect.UndeclaredThrowableException; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.function.Supplier; import org.jspecify.annotations.Nullable; @@ -120,19 +122,6 @@ public class RetryTemplate implements RetryOperations { } - /** - * Execute the supplied {@link Retryable} operation according to the configured - * {@link RetryPolicy}. - *

If the {@code Retryable} succeeds, its result will be returned. Otherwise, a - * {@link RetryException} will be thrown to the caller. The {@code RetryException} - * will contain the last exception thrown by the {@code Retryable} operation as the - * {@linkplain RetryException#getCause() cause} and any exceptions from previous - * attempts as {@linkplain RetryException#getSuppressed() suppressed exceptions}. - * @param retryable the {@code Retryable} to execute and retry if needed - * @param the type of the result - * @return the result of the {@code Retryable}, if any - * @throws RetryException if the {@code RetryPolicy} is exhausted - */ @Override public R execute(Retryable retryable) throws RetryException { long startTime = System.currentTimeMillis(); @@ -239,6 +228,32 @@ public class RetryTemplate implements RetryOperations { } } + @Override + public R invoke(Supplier retryable) { + try { + return execute(new Retryable<>() { + @Override + public R execute() { + return retryable.get(); + } + @Override + public String getName() { + return retryable.getClass().getName(); + } + }); + } + catch (RetryException retryException) { + Throwable ex = retryException.getCause(); + if (ex instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (ex instanceof Error error) { + throw error; + } + throw new UndeclaredThrowableException(ex); + } + } + private static class MutableRetryState implements RetryState { 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 83e8745a5da..fbbfb0b922d 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 @@ -23,6 +23,7 @@ import java.util.List; import java.util.Objects; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; +import java.util.function.Supplier; import org.assertj.core.api.ThrowingConsumer; import org.junit.jupiter.api.BeforeEach; @@ -79,7 +80,7 @@ class RetryTemplateTests { } @Test - void retryWithImmediateSuccess() throws Exception { + void retryableWithImmediateSuccess() throws Exception { AtomicInteger invocationCount = new AtomicInteger(); Retryable retryable = () -> { invocationCount.incrementAndGet(); @@ -97,7 +98,7 @@ class RetryTemplateTests { } @Test - void retryWithInitialFailureAndZeroRetriesRetryPolicy() { + void retryableWithInitialFailureAndZeroRetriesRetryPolicy() { RetryPolicy retryPolicy = throwable -> false; // Zero retries RetryTemplate retryTemplate = new RetryTemplate(retryPolicy); retryTemplate.setRetryListener(retryListener); @@ -121,9 +122,8 @@ class RetryTemplateTests { verifyNoMoreInteractions(retryListener); } - @Test - void retryWithInitialFailureAndZeroRetriesFixedBackOffPolicy() { + void retryableWithInitialFailureAndZeroRetriesFixedBackOffPolicy() { RetryPolicy retryPolicy = RetryPolicy.withMaxRetries(0); RetryTemplate retryTemplate = new RetryTemplate(retryPolicy); @@ -149,7 +149,7 @@ class RetryTemplateTests { } @Test - void retryWithInitialFailureAndZeroRetriesBackOffPolicyFromBuilder() { + void retryableWithInitialFailureAndZeroRetriesBackOffPolicyFromBuilder() { RetryPolicy retryPolicy = RetryPolicy.builder().maxRetries(0).build(); RetryTemplate retryTemplate = new RetryTemplate(retryPolicy); @@ -175,7 +175,7 @@ class RetryTemplateTests { } @Test - void retryWithSuccessAfterInitialFailures() throws Exception { + void retryableWithSuccessAfterInitialFailures() throws Exception { AtomicInteger invocationCount = new AtomicInteger(); Retryable retryable = () -> { if (invocationCount.incrementAndGet() <= 2) { @@ -201,7 +201,7 @@ class RetryTemplateTests { } @Test - void retryWithExhaustedPolicy() { + void retryableWithExhaustedPolicy() { var invocationCount = new AtomicInteger(); var retryable = new Retryable<>() { @@ -239,7 +239,7 @@ class RetryTemplateTests { } @Test - void retryWithInterruptionDuringSleep() { + void retryableWithInterruptionDuringSleep() { Exception exception = new RuntimeException("Boom!"); InterruptedException interruptedException = new InterruptedException(); @@ -269,7 +269,7 @@ class RetryTemplateTests { } @Test - void retryWithFailingRetryableAndMultiplePredicates() { + void retryableWithFailingRetryableAndMultiplePredicates() { var invocationCount = new AtomicInteger(); var exception = new NumberFormatException("Boom!"); @@ -316,7 +316,7 @@ class RetryTemplateTests { } @Test - void retryWithExceptionIncludes() { + void retryableWithExceptionIncludes() { var invocationCount = new AtomicInteger(); var retryable = new Retryable<>() { @@ -387,7 +387,7 @@ class RetryTemplateTests { @ParameterizedTest @FieldSource("includesAndExcludesRetryPolicies") - void retryWithExceptionIncludesAndExcludes(RetryPolicy retryPolicy) { + void retryableWithExceptionIncludesAndExcludes(RetryPolicy retryPolicy) { retryTemplate.setRetryPolicy(retryPolicy); var invocationCount = new AtomicInteger(); @@ -436,12 +436,105 @@ class RetryTemplateTests { verifyNoMoreInteractions(retryListener); } + @Test + void supplierWithImmediateSuccess() { + AtomicInteger invocationCount = new AtomicInteger(); + Supplier retryable = () -> { + invocationCount.incrementAndGet(); + return "always succeeds"; + }; + + assertThat(invocationCount).hasValue(0); + assertThat(retryTemplate.invoke(retryable)).isEqualTo("always succeeds"); + assertThat(invocationCount).hasValue(1); + + // RetryListener interactions: + inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), + argThat(r -> r.getName().equals(retryable.getClass().getName())), + argThat(state -> state.isSuccessful() && state.getRetryCount() == 0)); + verifyNoMoreInteractions(retryListener); + } + + @Test + void supplierWithSuccessAfterInitialFailures() { + AtomicInteger invocationCount = new AtomicInteger(); + Supplier retryable = () -> { + if (invocationCount.incrementAndGet() <= 2) { + throw new CustomException("Boom " + invocationCount.get()); + } + return "finally succeeded"; + }; + + assertThat(invocationCount).hasValue(0); + assertThat(retryTemplate.invoke(retryable)).isEqualTo("finally succeeded"); + assertThat(invocationCount).hasValue(3); + + // RetryListener interactions: + inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), + argThat(r -> r.getName().equals(retryable.getClass().getName())), + any(RetryState.class)); + inOrder.verify(retryListener).beforeRetry(eq(retryPolicy), + argThat(r -> r.getName().equals(retryable.getClass().getName()))); + inOrder.verify(retryListener).onRetryFailure(eq(retryPolicy), + argThat(r -> r.getName().equals(retryable.getClass().getName())), + eq(new CustomException("Boom 2"))); + inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), + argThat(r -> r.getName().equals(retryable.getClass().getName())), + any(RetryState.class)); + inOrder.verify(retryListener).beforeRetry(eq(retryPolicy), + argThat(r -> r.getName().equals(retryable.getClass().getName()))); + inOrder.verify(retryListener).onRetrySuccess(eq(retryPolicy), + argThat(r -> r.getName().equals(retryable.getClass().getName())), + eq("finally succeeded")); + inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), + argThat(r -> r.getName().equals(retryable.getClass().getName())), + argThat(state -> state.isSuccessful() && state.getRetryCount() == 2)); + verifyNoMoreInteractions(retryListener); + } + + @Test + void supplierWithExhaustedPolicy() { + AtomicInteger invocationCount = new AtomicInteger(); + Supplier retryable = () -> { + throw new CustomException("Boom " + invocationCount.incrementAndGet()); + }; + + assertThat(invocationCount).hasValue(0); + assertThatExceptionOfType(CustomException.class) + .isThrownBy(() -> retryTemplate.invoke(retryable)) + .withMessage("Boom 4") + .satisfies(throwable -> { + var counter = new AtomicInteger(1); + repeat(3, () -> { + inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), + argThat(r -> r.getName().equals(retryable.getClass().getName())), + any(RetryState.class)); + inOrder.verify(retryListener).beforeRetry(eq(retryPolicy), + argThat(r -> r.getName().equals(retryable.getClass().getName()))); + inOrder.verify(retryListener).onRetryFailure(eq(retryPolicy), + argThat(r -> r.getName().equals(retryable.getClass().getName())), + eq(new CustomException("Boom " + counter.incrementAndGet()))); + }); + inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy), + argThat(r -> r.getName().equals(retryable.getClass().getName())), + argThat(state -> !state.isSuccessful() && state.getRetryCount() == 3)); + inOrder.verify(retryListener).onRetryPolicyExhaustion(eq(retryPolicy), + argThat(r -> r.getName().equals(retryable.getClass().getName())), + argThat(t -> t.getMessage().equals("Retry policy for operation '" + + retryable.getClass().getName() + "' exhausted; aborting execution"))); + }); + // 4 = 1 initial invocation + 3 retry attempts + assertThat(invocationCount).hasValue(4); + + verifyNoMoreInteractions(retryListener); + } + @Nested class TimeoutTests { @Test - void retryWithImmediateSuccessAndTimeoutExceeded() throws Exception { + void retryableWithImmediateSuccessAndTimeoutExceeded() throws Exception { RetryPolicy retryPolicy = RetryPolicy.builder().timeout(Duration.ofMillis(10)).build(); RetryTemplate retryTemplate = new RetryTemplate(retryPolicy); retryTemplate.setRetryListener(retryListener); @@ -464,7 +557,7 @@ class RetryTemplateTests { } @Test - void retryWithInitialFailureAndZeroRetriesRetryPolicyAndTimeoutExceeded() { + void retryableWithInitialFailureAndZeroRetriesRetryPolicyAndTimeoutExceeded() { RetryPolicy retryPolicy = RetryPolicy.builder() .timeout(Duration.ofMillis(10)) .predicate(throwable -> false) // Zero retries @@ -492,7 +585,7 @@ class RetryTemplateTests { } @Test - void retryWithTimeoutExceededAfterInitialFailure() { + void retryableWithTimeoutExceededAfterInitialFailure() { RetryPolicy retryPolicy = RetryPolicy.builder() .timeout(Duration.ofMillis(10)) .delay(Duration.ZERO) @@ -521,7 +614,7 @@ class RetryTemplateTests { } @Test - void retryWithTimeoutExceededAfterFirstDelayButBeforeFirstRetry() { + void retryableWithTimeoutExceededAfterFirstDelayButBeforeFirstRetry() { RetryPolicy retryPolicy = RetryPolicy.builder() .timeout(Duration.ofMillis(20)) .delay(Duration.ofMillis(100)) // Delay > Timeout @@ -552,7 +645,7 @@ class RetryTemplateTests { } @Test - void retryWithTimeoutExceededAfterFirstRetry() { + void retryableWithTimeoutExceededAfterFirstRetry() { RetryPolicy retryPolicy = RetryPolicy.builder() .timeout(Duration.ofMillis(20)) .delay(Duration.ZERO) @@ -589,7 +682,7 @@ class RetryTemplateTests { } @Test - void retryWithTimeoutExceededAfterSecondRetry() { + void retryableWithTimeoutExceededAfterSecondRetry() { RetryPolicy retryPolicy = RetryPolicy.builder() .timeout(Duration.ofMillis(20)) .delay(Duration.ZERO)