Skip to content

Commit 2489cce

Browse files
committed
Expose RetryException#getRetryCount() and accept maxAttempts(0)
Closes gh-35351 Closes gh-35362
1 parent 472f844 commit 2489cce

File tree

8 files changed

+128
-55
lines changed

8 files changed

+128
-55
lines changed

spring-context/src/test/java/org/springframework/resilience/ReactiveRetryInterceptorTests.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,26 @@ void adaptReactiveResultWithMinimalRetrySpec() {
189189
assertThat(target.counter.get()).isEqualTo(2);
190190
}
191191

192+
@Test
193+
void adaptReactiveResultWithZeroAttempts() {
194+
// Test minimal retry configuration: maxAttempts=1, delay=0, jitter=0, multiplier=1.0, maxDelay=0
195+
MinimalRetryBean target = new MinimalRetryBean();
196+
ProxyFactory pf = new ProxyFactory();
197+
pf.setTarget(target);
198+
pf.addAdvice(new SimpleRetryInterceptor(
199+
new MethodRetrySpec((m, t) -> true, 0, Duration.ZERO, Duration.ZERO, 1.0, Duration.ZERO)));
200+
MinimalRetryBean proxy = (MinimalRetryBean) pf.getProxy();
201+
202+
// Should execute only 1 time, because maxAttempts=0 means initial call only
203+
assertThatIllegalStateException()
204+
.isThrownBy(() -> proxy.retryOperation().block())
205+
.satisfies(isRetryExhaustedException())
206+
.havingCause()
207+
.isInstanceOf(IOException.class)
208+
.withMessage("1");
209+
assertThat(target.counter.get()).isEqualTo(1);
210+
}
211+
192212
@Test
193213
void adaptReactiveResultWithZeroDelayAndJitter() {
194214
// Test case where delay=0 and jitter>0

spring-context/src/test/java/org/springframework/resilience/RetryInterceptorTests.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,31 @@ void withPostProcessorForClassWithStrings() {
194194
assertThat(target.counter).isEqualTo(6);
195195
}
196196

197+
@Test
198+
void withPostProcessorForClassWithZeroAttempts() {
199+
Properties props = new Properties();
200+
props.setProperty("delay", "10");
201+
props.setProperty("jitter", "5");
202+
props.setProperty("multiplier", "2.0");
203+
props.setProperty("maxDelay", "40");
204+
props.setProperty("limitedAttempts", "0");
205+
206+
GenericApplicationContext ctx = new GenericApplicationContext();
207+
ctx.getEnvironment().getPropertySources().addFirst(new PropertiesPropertySource("props", props));
208+
ctx.registerBeanDefinition("bean", new RootBeanDefinition(AnnotatedClassBeanWithStrings.class));
209+
ctx.registerBeanDefinition("bpp", new RootBeanDefinition(RetryAnnotationBeanPostProcessor.class));
210+
ctx.refresh();
211+
AnnotatedClassBeanWithStrings proxy = ctx.getBean(AnnotatedClassBeanWithStrings.class);
212+
AnnotatedClassBeanWithStrings target = (AnnotatedClassBeanWithStrings) AopProxyUtils.getSingletonTarget(proxy);
213+
214+
assertThatIOException().isThrownBy(proxy::retryOperation).withMessage("3");
215+
assertThat(target.counter).isEqualTo(3);
216+
assertThatIOException().isThrownBy(proxy::otherOperation);
217+
assertThat(target.counter).isEqualTo(4);
218+
assertThatIOException().isThrownBy(proxy::overrideOperation);
219+
assertThat(target.counter).isEqualTo(5);
220+
}
221+
197222
@Test
198223
void withEnableAnnotation() throws Exception {
199224
AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext();

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ class DefaultRetryPolicy implements RetryPolicy {
4545
private final BackOff backOff;
4646

4747

48-
4948
DefaultRetryPolicy(Set<Class<? extends Throwable>> includes, Set<Class<? extends Throwable>> excludes,
5049
@Nullable Predicate<Throwable> predicate, BackOff backOff) {
5150

@@ -59,8 +58,8 @@ class DefaultRetryPolicy implements RetryPolicy {
5958

6059
@Override
6160
public boolean shouldRetry(Throwable throwable) {
62-
return this.exceptionFilter.match(throwable) &&
63-
(this.predicate == null || this.predicate.test(throwable));
61+
return (this.exceptionFilter.match(throwable) &&
62+
(this.predicate == null || this.predicate.test(throwable)));
6463
}
6564

6665
@Override

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
* exceptions}.
2929
*
3030
* @author Mahmoud Ben Hassine
31+
* @author Juergen Hoeller
3132
* @since 7.0
3233
* @see RetryOperations
3334
*/
@@ -51,8 +52,16 @@ public RetryException(String message, Throwable cause) {
5152
* Get the last exception thrown by the {@link Retryable} operation.
5253
*/
5354
@Override
54-
public final synchronized Throwable getCause() {
55+
public final Throwable getCause() {
5556
return Objects.requireNonNull(super.getCause());
5657
}
5758

59+
/**
60+
* Return the number of retry attempts, or 0 if no retry has been attempted
61+
* after the initial invocation at all.
62+
*/
63+
public int getRetryCount() {
64+
return getSuppressed().length;
65+
}
66+
5867
}

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

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,13 @@ static RetryPolicy withDefaults() {
8282
* Create a {@link RetryPolicy} configured with a maximum number of retry attempts.
8383
* <p>The returned policy uses a fixed backoff of {@value Builder#DEFAULT_DELAY}
8484
* milliseconds.
85-
* @param maxAttempts the maximum number of retry attempts; must be greater than zero
85+
* @param maxAttempts the maximum number of retry attempts;
86+
* must be positive (or zero for no retry)
8687
* @see Builder#maxAttempts(long)
8788
* @see FixedBackOff
8889
*/
8990
static RetryPolicy withMaxAttempts(long maxAttempts) {
90-
assertMaxAttemptsIsPositive(maxAttempts);
91+
assertMaxAttemptsIsNotNegative(maxAttempts);
9192
return builder().backOff(new FixedBackOff(Builder.DEFAULT_DELAY, maxAttempts)).build();
9293
}
9394

@@ -100,21 +101,21 @@ static Builder builder() {
100101
}
101102

102103

103-
private static void assertMaxAttemptsIsPositive(long maxAttempts) {
104-
Assert.isTrue(maxAttempts > 0,
105-
() -> "Invalid maxAttempts (%d): must be greater than zero.".formatted(maxAttempts));
106-
}
107-
108-
private static void assertIsPositive(String name, Duration duration) {
109-
Assert.isTrue((!duration.isNegative() && !duration.isZero()),
110-
() -> "Invalid %s (%dms): must be greater than zero.".formatted(name, duration.toMillis()));
104+
private static void assertMaxAttemptsIsNotNegative(long maxAttempts) {
105+
Assert.isTrue(maxAttempts >= 0,
106+
() -> "Invalid maxAttempts (%d): must be positive or zero for no retry.".formatted(maxAttempts));
111107
}
112108

113109
private static void assertIsNotNegative(String name, Duration duration) {
114110
Assert.isTrue(!duration.isNegative(),
115111
() -> "Invalid %s (%dms): must be greater than or equal to zero.".formatted(name, duration.toMillis()));
116112
}
117113

114+
private static void assertIsPositive(String name, Duration duration) {
115+
Assert.isTrue((!duration.isNegative() && !duration.isZero()),
116+
() -> "Invalid %s (%dms): must be greater than zero.".formatted(name, duration.toMillis()));
117+
}
118+
118119

119120
/**
120121
* Fluent API for configuring a {@link RetryPolicy} with common configuration
@@ -146,13 +147,13 @@ final class Builder {
146147

147148
private @Nullable BackOff backOff;
148149

149-
private long maxAttempts;
150+
private @Nullable Long maxAttempts;
150151

151152
private @Nullable Duration delay;
152153

153154
private @Nullable Duration jitter;
154155

155-
private double multiplier;
156+
private @Nullable Double multiplier;
156157

157158
private @Nullable Duration maxDelay;
158159

@@ -191,12 +192,12 @@ public Builder backOff(BackOff backOff) {
191192
* <p>The supplied value will override any previously configured value.
192193
* <p>You should not specify this configuration option if you have
193194
* configured a custom {@link #backOff(BackOff) BackOff} strategy.
194-
* @param maxAttempts the maximum number of retry attempts; must be
195-
* greater than zero
195+
* @param maxAttempts the maximum number of retry attempts;
196+
* must be positive (or zero for no retry)
196197
* @return this {@code Builder} instance for chained method invocations
197198
*/
198199
public Builder maxAttempts(long maxAttempts) {
199-
assertMaxAttemptsIsPositive(maxAttempts);
200+
assertMaxAttemptsIsNotNegative(maxAttempts);
200201
this.maxAttempts = maxAttempts;
201202
return this;
202203
}
@@ -399,18 +400,18 @@ public Builder predicate(Predicate<Throwable> predicate) {
399400
public RetryPolicy build() {
400401
BackOff backOff = this.backOff;
401402
if (backOff != null) {
402-
boolean misconfigured = (this.maxAttempts != 0) || (this.delay != null) || (this.jitter != null) ||
403-
(this.multiplier != 0) || (this.maxDelay != null);
403+
boolean misconfigured = (this.maxAttempts != null || this.delay != null || this.jitter != null ||
404+
this.multiplier != null || this.maxDelay != null);
404405
Assert.state(!misconfigured, """
405406
The following configuration options are not supported with a custom BackOff strategy: \
406407
maxAttempts, delay, jitter, multiplier, or maxDelay.""");
407408
}
408409
else {
409410
ExponentialBackOff exponentialBackOff = new ExponentialBackOff();
410-
exponentialBackOff.setMaxAttempts(this.maxAttempts > 0 ? this.maxAttempts : DEFAULT_MAX_ATTEMPTS);
411+
exponentialBackOff.setMaxAttempts(this.maxAttempts != null ? this.maxAttempts : DEFAULT_MAX_ATTEMPTS);
411412
exponentialBackOff.setInitialInterval(this.delay != null ? this.delay.toMillis() : DEFAULT_DELAY);
412413
exponentialBackOff.setMaxInterval(this.maxDelay != null ? this.maxDelay.toMillis() : DEFAULT_MAX_DELAY);
413-
exponentialBackOff.setMultiplier(this.multiplier > 1 ? this.multiplier : DEFAULT_MULTIPLIER);
414+
exponentialBackOff.setMultiplier(this.multiplier != null ? this.multiplier : DEFAULT_MULTIPLIER);
414415
if (this.jitter != null) {
415416
exponentialBackOff.setJitter(this.jitter.toMillis());
416417
}

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,18 @@ void maxAttempts() {
5454
assertThat(backOffExecution.nextBackOff()).isEqualTo(STOP);
5555
}
5656

57+
@Test
58+
void maxAttemptsZero() {
59+
var retryPolicy = RetryPolicy.builder().maxAttempts(0).delay(Duration.ZERO).build();
60+
var backOffExecution = retryPolicy.getBackOff().start();
61+
var throwable = mock(Throwable.class);
62+
63+
assertThat(retryPolicy.shouldRetry(throwable)).isTrue();
64+
assertThat(backOffExecution.nextBackOff()).isEqualTo(STOP);
65+
assertThat(retryPolicy.shouldRetry(throwable)).isTrue();
66+
assertThat(backOffExecution.nextBackOff()).isEqualTo(STOP);
67+
}
68+
5769
@Test
5870
void maxAttemptsAndPredicate() {
5971
var retryPolicy = RetryPolicy.builder()
@@ -115,6 +127,7 @@ void maxAttemptsWithIncludesAndExcludes() {
115127
private static class CustomNumberFormatException extends NumberFormatException {
116128
}
117129

130+
118131
@SuppressWarnings("serial")
119132
private static class CustomFileSystemException extends FileSystemException {
120133

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

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,9 @@ void withDefaults() {
6565

6666
@Test
6767
void withMaxAttemptsPreconditions() {
68-
assertThatIllegalArgumentException()
69-
.isThrownBy(() -> RetryPolicy.withMaxAttempts(0))
70-
.withMessage("Invalid maxAttempts (0): must be greater than zero.");
7168
assertThatIllegalArgumentException()
7269
.isThrownBy(() -> RetryPolicy.withMaxAttempts(-1))
73-
.withMessage("Invalid maxAttempts (-1): must be greater than zero.");
70+
.withMessageStartingWith("Invalid maxAttempts (-1)");
7471
}
7572

7673
@Test
@@ -115,12 +112,9 @@ void backOff() {
115112

116113
@Test
117114
void maxAttemptsPreconditions() {
118-
assertThatIllegalArgumentException()
119-
.isThrownBy(() -> RetryPolicy.builder().maxAttempts(0))
120-
.withMessage("Invalid maxAttempts (0): must be greater than zero.");
121115
assertThatIllegalArgumentException()
122116
.isThrownBy(() -> RetryPolicy.builder().maxAttempts(-1))
123-
.withMessage("Invalid maxAttempts (-1): must be greater than zero.");
117+
.withMessageStartingWith("Invalid maxAttempts (-1)");
124118
}
125119

126120
@Test

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

Lines changed: 36 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,6 @@
3232
import org.junit.jupiter.params.provider.FieldSource;
3333
import org.mockito.InOrder;
3434

35-
import org.springframework.util.backoff.BackOff;
36-
import org.springframework.util.backoff.FixedBackOff;
37-
3835
import static org.assertj.core.api.Assertions.assertThat;
3936
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
4037
import static org.junit.jupiter.params.provider.Arguments.argumentSet;
@@ -51,16 +48,13 @@
5148
*
5249
* @author Mahmoud Ben Hassine
5350
* @author Sam Brannen
51+
* @author Juergen Hoeller
5452
* @since 7.0
5553
* @see RetryPolicyTests
5654
*/
5755
class RetryTemplateTests {
5856

59-
private final RetryPolicy retryPolicy =
60-
RetryPolicy.builder()
61-
.maxAttempts(3)
62-
.delay(Duration.ZERO)
63-
.build();
57+
private final RetryPolicy retryPolicy = RetryPolicy.builder().maxAttempts(3).delay(Duration.ZERO).build();
6458

6559
private final RetryTemplate retryTemplate = new RetryTemplate(retryPolicy);
6660

@@ -104,28 +98,41 @@ void retryWithInitialFailureAndZeroRetriesRetryPolicy() {
10498
.isThrownBy(() -> retryTemplate.execute(retryable))
10599
.withMessageMatching("Retry policy for operation '.+?' exhausted; aborting execution")
106100
.withCause(exception)
107-
.satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty());
101+
.satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty())
102+
.satisfies(throwable -> assertThat(throwable.getRetryCount()).isZero());
108103

109104
// RetryListener interactions:
110105
inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, exception);
111106
verifyNoMoreInteractions(retryListener);
112107
}
113108

114109
@Test
115-
void retryWithInitialFailureAndZeroRetriesBackOffPolicy() {
116-
RetryPolicy retryPolicy = new RetryPolicy() {
110+
void retryWithInitialFailureAndZeroRetriesFixedBackOffPolicy() {
111+
RetryPolicy retryPolicy = RetryPolicy.withMaxAttempts(0);
117112

118-
@Override
119-
public boolean shouldRetry(Throwable throwable) {
120-
return true;
121-
}
122-
123-
@Override
124-
public BackOff getBackOff() {
125-
return new FixedBackOff(10, 0); // Zero retries
126-
}
113+
RetryTemplate retryTemplate = new RetryTemplate(retryPolicy);
114+
retryTemplate.setRetryListener(retryListener);
115+
Exception exception = new RuntimeException("Boom!");
116+
Retryable<String> retryable = () -> {
117+
throw exception;
127118
};
128119

120+
assertThatExceptionOfType(RetryException.class)
121+
.isThrownBy(() -> retryTemplate.execute(retryable))
122+
.withMessageMatching("Retry policy for operation '.+?' exhausted; aborting execution")
123+
.withCause(exception)
124+
.satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty())
125+
.satisfies(throwable -> assertThat(throwable.getRetryCount()).isZero());
126+
127+
// RetryListener interactions:
128+
inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, exception);
129+
verifyNoMoreInteractions(retryListener);
130+
}
131+
132+
@Test
133+
void retryWithInitialFailureAndZeroRetriesBackOffPolicyFromBuilder() {
134+
RetryPolicy retryPolicy = RetryPolicy.builder().maxAttempts(0).build();
135+
129136
RetryTemplate retryTemplate = new RetryTemplate(retryPolicy);
130137
retryTemplate.setRetryListener(retryListener);
131138
Exception exception = new RuntimeException("Boom!");
@@ -137,7 +144,8 @@ public BackOff getBackOff() {
137144
.isThrownBy(() -> retryTemplate.execute(retryable))
138145
.withMessageMatching("Retry policy for operation '.+?' exhausted; aborting execution")
139146
.withCause(exception)
140-
.satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty());
147+
.satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty())
148+
.satisfies(throwable -> assertThat(throwable.getRetryCount()).isZero());
141149

142150
// RetryListener interactions:
143151
inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, exception);
@@ -282,7 +290,8 @@ public String getName() {
282290
.satisfies(hasSuppressedExceptionsSatisfyingExactly(
283291
suppressed1 -> assertThat(suppressed1).isExactlyInstanceOf(FileNotFoundException.class),
284292
suppressed2 -> assertThat(suppressed2).isExactlyInstanceOf(IOException.class)
285-
));
293+
))
294+
.satisfies(throwable -> assertThat(throwable.getRetryCount()).isEqualTo(2));
286295
// 3 = 1 initial invocation + 2 retry attempts
287296
assertThat(invocationCount).hasValue(3);
288297

@@ -344,7 +353,8 @@ public String getName() {
344353
.satisfies(hasSuppressedExceptionsSatisfyingExactly(
345354
suppressed1 -> assertThat(suppressed1).isExactlyInstanceOf(IOException.class),
346355
suppressed2 -> assertThat(suppressed2).isExactlyInstanceOf(IOException.class)
347-
));
356+
))
357+
.satisfies(throwable -> assertThat(throwable.getRetryCount()).isEqualTo(2));
348358
// 3 = 1 initial invocation + 2 retry attempts
349359
assertThat(invocationCount).hasValue(3);
350360

@@ -366,8 +376,9 @@ private static void repeat(int times, Runnable runnable) {
366376
}
367377

368378
@SafeVarargs
369-
private static final Consumer<Throwable> hasSuppressedExceptionsSatisfyingExactly(
379+
private static Consumer<Throwable> hasSuppressedExceptionsSatisfyingExactly(
370380
ThrowingConsumer<? super Throwable>... requirements) {
381+
371382
return throwable -> assertThat(throwable.getSuppressed()).satisfiesExactly(requirements);
372383
}
373384

@@ -376,6 +387,7 @@ private static final Consumer<Throwable> hasSuppressedExceptionsSatisfyingExactl
376387
private static class CustomFileNotFoundException extends FileNotFoundException {
377388
}
378389

390+
379391
/**
380392
* Custom {@link RuntimeException} that implements {@link #equals(Object)}
381393
* and {@link #hashCode()} for use in assertions that check for equality.

0 commit comments

Comments
 (0)