Browse Source

Introduce invoke(Supplier) with last original RuntimeException propagated

Closes gh-36052
pull/36062/head
Juergen Hoeller 1 month ago
parent
commit
faa17abdae
  1. 22
      spring-core/src/main/java/org/springframework/core/retry/RetryOperations.java
  2. 41
      spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java
  3. 127
      spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java

22
spring-core/src/main/java/org/springframework/core/retry/RetryOperations.java

@ -16,6 +16,8 @@ @@ -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; @@ -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 { @@ -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);
}

41
spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java

@ -16,9 +16,11 @@ @@ -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 { @@ -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 { @@ -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 {

127
spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java

@ -23,6 +23,7 @@ import java.util.List; @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -589,7 +682,7 @@ class RetryTemplateTests {
}
@Test
void retryWithTimeoutExceededAfterSecondRetry() {
void retryableWithTimeoutExceededAfterSecondRetry() {
RetryPolicy retryPolicy = RetryPolicy.builder()
.timeout(Duration.ofMillis(20))
.delay(Duration.ZERO)

Loading…
Cancel
Save