mirror of
https://github.com/spring-projects/spring-framework.git
synced 2026-09-28 18:19:05 +00:00
Introduce invoke(Supplier) with last original RuntimeException propagated
Closes gh-36052
This commit is contained in:
@@ -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 <R> 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 extends @Nullable Object> R execute(Retryable<R> 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 <R> 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 extends @Nullable Object> R invoke(Supplier<R> retryable);
|
||||
|
||||
}
|
||||
|
||||
@@ -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}.
|
||||
* <p>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 <R> 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 extends @Nullable Object> R execute(Retryable<R> retryable) throws RetryException {
|
||||
long startTime = System.currentTimeMillis();
|
||||
@@ -239,6 +228,32 @@ public class RetryTemplate implements RetryOperations {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public <R extends @Nullable Object> R invoke(Supplier<R> 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 {
|
||||
|
||||
|
||||
@@ -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<String> 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<String> 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<String> 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<String> 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<String> 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)
|
||||
|
||||
Reference in New Issue
Block a user