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 1548c78bdcc..e5cbab01b17 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 @@ -66,4 +66,14 @@ public interface RetryOperations { */ R invoke(Supplier retryable); + /** + * Invoke the given {@link Runnable} according to the {@link RetryPolicy}, + * returning successfully or throwing the last {@code Runnable} exception + * to the caller in case of retry policy exhaustion. + * @param retryable the {@code Runnable} to invoke and retry if needed + * @throws RuntimeException if thrown by the {@code Runnable} + * @since 7.0.3 + */ + void invoke(Runnable 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 11db26d6942..ba89a5417b6 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 @@ -254,6 +254,33 @@ public class RetryTemplate implements RetryOperations { } } + @Override + public void invoke(Runnable retryable) { + try { + execute(new Retryable<>() { + @Override + public Void execute() { + retryable.run(); + return null; + } + @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 fbbfb0b922d..775855dcace 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 @@ -39,6 +39,7 @@ import org.springframework.util.backoff.BackOff; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNoException; import static org.junit.jupiter.params.provider.Arguments.argumentSet; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; @@ -529,6 +530,95 @@ class RetryTemplateTests { verifyNoMoreInteractions(retryListener); } + @Test + void runnableWithImmediateSuccess() { + AtomicInteger invocationCount = new AtomicInteger(); + Runnable retryable = invocationCount::incrementAndGet; + + assertThat(invocationCount).hasValue(0); + assertThatNoException().isThrownBy(() -> retryTemplate.invoke(retryable)); + 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 runnableWithSuccessAfterInitialFailures() { + AtomicInteger invocationCount = new AtomicInteger(); + Runnable retryable = () -> { + if (invocationCount.incrementAndGet() <= 2) { + throw new CustomException("Boom " + invocationCount.get()); + } + }; + + assertThat(invocationCount).hasValue(0); + assertThatNoException().isThrownBy(() -> retryTemplate.invoke(retryable)); + 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(null)); + 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 runnableWithExhaustedPolicy() { + AtomicInteger invocationCount = new AtomicInteger(); + Runnable 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 {