Skip to content

Commit faa17ab

Browse files
committed
Introduce invoke(Supplier) with last original RuntimeException propagated
Closes gh-36052
1 parent 89ca8e6 commit faa17ab

File tree

3 files changed

+158
-32
lines changed

3 files changed

+158
-32
lines changed

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
package org.springframework.core.retry;
1818

19+
import java.util.function.Supplier;
20+
1921
import org.jspecify.annotations.Nullable;
2022

2123
/**
@@ -28,6 +30,7 @@
2830
* project but redesigned as a minimal core retry feature in the Spring Framework.
2931
*
3032
* @author Mahmoud Ben Hassine
33+
* @author Juergen Hoeller
3134
* @since 7.0
3235
* @see RetryTemplate
3336
*/
@@ -43,9 +46,24 @@ public interface RetryOperations {
4346
* attempts as {@linkplain RetryException#getSuppressed() suppressed exceptions}.
4447
* @param retryable the {@code Retryable} to execute and retry if needed
4548
* @param <R> the type of the result
46-
* @return the result of the {@code Retryable}, if any
47-
* @throws RetryException if the {@code RetryPolicy} is exhausted
49+
* @return the successful result of the {@code Retryable}, if any
50+
* @throws RetryException if the {@code RetryPolicy} is exhausted. Note that this
51+
* exception represents a failure outcome and is not meant to be propagated; you
52+
* will typically rather rethrow its cause (the last original exception thrown by
53+
* the {@code Retryable} callback) or throw a custom business exception instead.
4854
*/
4955
<R extends @Nullable Object> R execute(Retryable<R> retryable) throws RetryException;
5056

57+
/**
58+
* Invoke the given {@link Supplier} according to the {@link RetryPolicy},
59+
* returning a successful result or throwing the last {@code Supplier} exception
60+
* to the caller in case of retry policy exhaustion.
61+
* @param retryable the {@code Supplier} to invoke and retry if needed
62+
* @param <R> the type of the result
63+
* @return the result of the {@code Supplier}
64+
* @throws RuntimeException if thrown by the {@code Supplier}
65+
* @since 7.0.3
66+
*/
67+
<R extends @Nullable Object> R invoke(Supplier<R> retryable);
68+
5169
}

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

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@
1616

1717
package org.springframework.core.retry;
1818

19+
import java.lang.reflect.UndeclaredThrowableException;
1920
import java.util.ArrayList;
2021
import java.util.Collections;
2122
import java.util.List;
23+
import java.util.function.Supplier;
2224

2325
import org.jspecify.annotations.Nullable;
2426

@@ -120,19 +122,6 @@ public RetryListener getRetryListener() {
120122
}
121123

122124

123-
/**
124-
* Execute the supplied {@link Retryable} operation according to the configured
125-
* {@link RetryPolicy}.
126-
* <p>If the {@code Retryable} succeeds, its result will be returned. Otherwise, a
127-
* {@link RetryException} will be thrown to the caller. The {@code RetryException}
128-
* will contain the last exception thrown by the {@code Retryable} operation as the
129-
* {@linkplain RetryException#getCause() cause} and any exceptions from previous
130-
* attempts as {@linkplain RetryException#getSuppressed() suppressed exceptions}.
131-
* @param retryable the {@code Retryable} to execute and retry if needed
132-
* @param <R> the type of the result
133-
* @return the result of the {@code Retryable}, if any
134-
* @throws RetryException if the {@code RetryPolicy} is exhausted
135-
*/
136125
@Override
137126
public <R extends @Nullable Object> R execute(Retryable<R> retryable) throws RetryException {
138127
long startTime = System.currentTimeMillis();
@@ -239,6 +228,32 @@ private void checkIfTimeoutExceeded(long timeout, long startTime, long sleepTime
239228
}
240229
}
241230

231+
@Override
232+
public <R extends @Nullable Object> R invoke(Supplier<R> retryable) {
233+
try {
234+
return execute(new Retryable<>() {
235+
@Override
236+
public R execute() {
237+
return retryable.get();
238+
}
239+
@Override
240+
public String getName() {
241+
return retryable.getClass().getName();
242+
}
243+
});
244+
}
245+
catch (RetryException retryException) {
246+
Throwable ex = retryException.getCause();
247+
if (ex instanceof RuntimeException runtimeException) {
248+
throw runtimeException;
249+
}
250+
if (ex instanceof Error error) {
251+
throw error;
252+
}
253+
throw new UndeclaredThrowableException(ex);
254+
}
255+
}
256+
242257

243258
private static class MutableRetryState implements RetryState {
244259

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

Lines changed: 110 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import java.util.Objects;
2424
import java.util.concurrent.atomic.AtomicInteger;
2525
import java.util.function.Consumer;
26+
import java.util.function.Supplier;
2627

2728
import org.assertj.core.api.ThrowingConsumer;
2829
import org.junit.jupiter.api.BeforeEach;
@@ -79,7 +80,7 @@ void checkRetryTemplateConfiguration() {
7980
}
8081

8182
@Test
82-
void retryWithImmediateSuccess() throws Exception {
83+
void retryableWithImmediateSuccess() throws Exception {
8384
AtomicInteger invocationCount = new AtomicInteger();
8485
Retryable<String> retryable = () -> {
8586
invocationCount.incrementAndGet();
@@ -97,7 +98,7 @@ void retryWithImmediateSuccess() throws Exception {
9798
}
9899

99100
@Test
100-
void retryWithInitialFailureAndZeroRetriesRetryPolicy() {
101+
void retryableWithInitialFailureAndZeroRetriesRetryPolicy() {
101102
RetryPolicy retryPolicy = throwable -> false; // Zero retries
102103
RetryTemplate retryTemplate = new RetryTemplate(retryPolicy);
103104
retryTemplate.setRetryListener(retryListener);
@@ -121,9 +122,8 @@ void retryWithInitialFailureAndZeroRetriesRetryPolicy() {
121122
verifyNoMoreInteractions(retryListener);
122123
}
123124

124-
125125
@Test
126-
void retryWithInitialFailureAndZeroRetriesFixedBackOffPolicy() {
126+
void retryableWithInitialFailureAndZeroRetriesFixedBackOffPolicy() {
127127
RetryPolicy retryPolicy = RetryPolicy.withMaxRetries(0);
128128

129129
RetryTemplate retryTemplate = new RetryTemplate(retryPolicy);
@@ -149,7 +149,7 @@ void retryWithInitialFailureAndZeroRetriesFixedBackOffPolicy() {
149149
}
150150

151151
@Test
152-
void retryWithInitialFailureAndZeroRetriesBackOffPolicyFromBuilder() {
152+
void retryableWithInitialFailureAndZeroRetriesBackOffPolicyFromBuilder() {
153153
RetryPolicy retryPolicy = RetryPolicy.builder().maxRetries(0).build();
154154

155155
RetryTemplate retryTemplate = new RetryTemplate(retryPolicy);
@@ -175,7 +175,7 @@ void retryWithInitialFailureAndZeroRetriesBackOffPolicyFromBuilder() {
175175
}
176176

177177
@Test
178-
void retryWithSuccessAfterInitialFailures() throws Exception {
178+
void retryableWithSuccessAfterInitialFailures() throws Exception {
179179
AtomicInteger invocationCount = new AtomicInteger();
180180
Retryable<String> retryable = () -> {
181181
if (invocationCount.incrementAndGet() <= 2) {
@@ -201,7 +201,7 @@ void retryWithSuccessAfterInitialFailures() throws Exception {
201201
}
202202

203203
@Test
204-
void retryWithExhaustedPolicy() {
204+
void retryableWithExhaustedPolicy() {
205205
var invocationCount = new AtomicInteger();
206206

207207
var retryable = new Retryable<>() {
@@ -239,7 +239,7 @@ public String getName() {
239239
}
240240

241241
@Test
242-
void retryWithInterruptionDuringSleep() {
242+
void retryableWithInterruptionDuringSleep() {
243243
Exception exception = new RuntimeException("Boom!");
244244
InterruptedException interruptedException = new InterruptedException();
245245

@@ -269,7 +269,7 @@ void retryWithInterruptionDuringSleep() {
269269
}
270270

271271
@Test
272-
void retryWithFailingRetryableAndMultiplePredicates() {
272+
void retryableWithFailingRetryableAndMultiplePredicates() {
273273
var invocationCount = new AtomicInteger();
274274
var exception = new NumberFormatException("Boom!");
275275

@@ -316,7 +316,7 @@ public String getName() {
316316
}
317317

318318
@Test
319-
void retryWithExceptionIncludes() {
319+
void retryableWithExceptionIncludes() {
320320
var invocationCount = new AtomicInteger();
321321

322322
var retryable = new Retryable<>() {
@@ -387,7 +387,7 @@ public String getName() {
387387

388388
@ParameterizedTest
389389
@FieldSource("includesAndExcludesRetryPolicies")
390-
void retryWithExceptionIncludesAndExcludes(RetryPolicy retryPolicy) {
390+
void retryableWithExceptionIncludesAndExcludes(RetryPolicy retryPolicy) {
391391
retryTemplate.setRetryPolicy(retryPolicy);
392392

393393
var invocationCount = new AtomicInteger();
@@ -436,12 +436,105 @@ public String getName() {
436436
verifyNoMoreInteractions(retryListener);
437437
}
438438

439+
@Test
440+
void supplierWithImmediateSuccess() {
441+
AtomicInteger invocationCount = new AtomicInteger();
442+
Supplier<String> retryable = () -> {
443+
invocationCount.incrementAndGet();
444+
return "always succeeds";
445+
};
446+
447+
assertThat(invocationCount).hasValue(0);
448+
assertThat(retryTemplate.invoke(retryable)).isEqualTo("always succeeds");
449+
assertThat(invocationCount).hasValue(1);
450+
451+
// RetryListener interactions:
452+
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy),
453+
argThat(r -> r.getName().equals(retryable.getClass().getName())),
454+
argThat(state -> state.isSuccessful() && state.getRetryCount() == 0));
455+
verifyNoMoreInteractions(retryListener);
456+
}
457+
458+
@Test
459+
void supplierWithSuccessAfterInitialFailures() {
460+
AtomicInteger invocationCount = new AtomicInteger();
461+
Supplier<String> retryable = () -> {
462+
if (invocationCount.incrementAndGet() <= 2) {
463+
throw new CustomException("Boom " + invocationCount.get());
464+
}
465+
return "finally succeeded";
466+
};
467+
468+
assertThat(invocationCount).hasValue(0);
469+
assertThat(retryTemplate.invoke(retryable)).isEqualTo("finally succeeded");
470+
assertThat(invocationCount).hasValue(3);
471+
472+
// RetryListener interactions:
473+
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy),
474+
argThat(r -> r.getName().equals(retryable.getClass().getName())),
475+
any(RetryState.class));
476+
inOrder.verify(retryListener).beforeRetry(eq(retryPolicy),
477+
argThat(r -> r.getName().equals(retryable.getClass().getName())));
478+
inOrder.verify(retryListener).onRetryFailure(eq(retryPolicy),
479+
argThat(r -> r.getName().equals(retryable.getClass().getName())),
480+
eq(new CustomException("Boom 2")));
481+
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy),
482+
argThat(r -> r.getName().equals(retryable.getClass().getName())),
483+
any(RetryState.class));
484+
inOrder.verify(retryListener).beforeRetry(eq(retryPolicy),
485+
argThat(r -> r.getName().equals(retryable.getClass().getName())));
486+
inOrder.verify(retryListener).onRetrySuccess(eq(retryPolicy),
487+
argThat(r -> r.getName().equals(retryable.getClass().getName())),
488+
eq("finally succeeded"));
489+
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy),
490+
argThat(r -> r.getName().equals(retryable.getClass().getName())),
491+
argThat(state -> state.isSuccessful() && state.getRetryCount() == 2));
492+
verifyNoMoreInteractions(retryListener);
493+
}
494+
495+
@Test
496+
void supplierWithExhaustedPolicy() {
497+
AtomicInteger invocationCount = new AtomicInteger();
498+
Supplier<String> retryable = () -> {
499+
throw new CustomException("Boom " + invocationCount.incrementAndGet());
500+
};
501+
502+
assertThat(invocationCount).hasValue(0);
503+
assertThatExceptionOfType(CustomException.class)
504+
.isThrownBy(() -> retryTemplate.invoke(retryable))
505+
.withMessage("Boom 4")
506+
.satisfies(throwable -> {
507+
var counter = new AtomicInteger(1);
508+
repeat(3, () -> {
509+
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy),
510+
argThat(r -> r.getName().equals(retryable.getClass().getName())),
511+
any(RetryState.class));
512+
inOrder.verify(retryListener).beforeRetry(eq(retryPolicy),
513+
argThat(r -> r.getName().equals(retryable.getClass().getName())));
514+
inOrder.verify(retryListener).onRetryFailure(eq(retryPolicy),
515+
argThat(r -> r.getName().equals(retryable.getClass().getName())),
516+
eq(new CustomException("Boom " + counter.incrementAndGet())));
517+
});
518+
inOrder.verify(retryListener).onRetryableExecution(eq(retryPolicy),
519+
argThat(r -> r.getName().equals(retryable.getClass().getName())),
520+
argThat(state -> !state.isSuccessful() && state.getRetryCount() == 3));
521+
inOrder.verify(retryListener).onRetryPolicyExhaustion(eq(retryPolicy),
522+
argThat(r -> r.getName().equals(retryable.getClass().getName())),
523+
argThat(t -> t.getMessage().equals("Retry policy for operation '" +
524+
retryable.getClass().getName() + "' exhausted; aborting execution")));
525+
});
526+
// 4 = 1 initial invocation + 3 retry attempts
527+
assertThat(invocationCount).hasValue(4);
528+
529+
verifyNoMoreInteractions(retryListener);
530+
}
531+
439532

440533
@Nested
441534
class TimeoutTests {
442535

443536
@Test
444-
void retryWithImmediateSuccessAndTimeoutExceeded() throws Exception {
537+
void retryableWithImmediateSuccessAndTimeoutExceeded() throws Exception {
445538
RetryPolicy retryPolicy = RetryPolicy.builder().timeout(Duration.ofMillis(10)).build();
446539
RetryTemplate retryTemplate = new RetryTemplate(retryPolicy);
447540
retryTemplate.setRetryListener(retryListener);
@@ -464,7 +557,7 @@ void retryWithImmediateSuccessAndTimeoutExceeded() throws Exception {
464557
}
465558

466559
@Test
467-
void retryWithInitialFailureAndZeroRetriesRetryPolicyAndTimeoutExceeded() {
560+
void retryableWithInitialFailureAndZeroRetriesRetryPolicyAndTimeoutExceeded() {
468561
RetryPolicy retryPolicy = RetryPolicy.builder()
469562
.timeout(Duration.ofMillis(10))
470563
.predicate(throwable -> false) // Zero retries
@@ -492,7 +585,7 @@ void retryWithInitialFailureAndZeroRetriesRetryPolicyAndTimeoutExceeded() {
492585
}
493586

494587
@Test
495-
void retryWithTimeoutExceededAfterInitialFailure() {
588+
void retryableWithTimeoutExceededAfterInitialFailure() {
496589
RetryPolicy retryPolicy = RetryPolicy.builder()
497590
.timeout(Duration.ofMillis(10))
498591
.delay(Duration.ZERO)
@@ -521,7 +614,7 @@ void retryWithTimeoutExceededAfterInitialFailure() {
521614
}
522615

523616
@Test
524-
void retryWithTimeoutExceededAfterFirstDelayButBeforeFirstRetry() {
617+
void retryableWithTimeoutExceededAfterFirstDelayButBeforeFirstRetry() {
525618
RetryPolicy retryPolicy = RetryPolicy.builder()
526619
.timeout(Duration.ofMillis(20))
527620
.delay(Duration.ofMillis(100)) // Delay > Timeout
@@ -552,7 +645,7 @@ void retryWithTimeoutExceededAfterFirstDelayButBeforeFirstRetry() {
552645
}
553646

554647
@Test
555-
void retryWithTimeoutExceededAfterFirstRetry() {
648+
void retryableWithTimeoutExceededAfterFirstRetry() {
556649
RetryPolicy retryPolicy = RetryPolicy.builder()
557650
.timeout(Duration.ofMillis(20))
558651
.delay(Duration.ZERO)
@@ -589,7 +682,7 @@ void retryWithTimeoutExceededAfterFirstRetry() {
589682
}
590683

591684
@Test
592-
void retryWithTimeoutExceededAfterSecondRetry() {
685+
void retryableWithTimeoutExceededAfterSecondRetry() {
593686
RetryPolicy retryPolicy = RetryPolicy.builder()
594687
.timeout(Duration.ofMillis(20))
595688
.delay(Duration.ZERO)

0 commit comments

Comments
 (0)