Rename maxAttempts to maxRetries in @⁠Retryable and RetryPolicy

Prior to this commit, the maximum number of retry attempts was
configured via @⁠Retryable(maxAttempts = ...),
RetryPolicy.withMaxAttempts(), and RetryPolicy.Builder.maxAttempts().
However, this led to confusion for developers who were unsure if
"max attempts" referred to the "total attempts" (i.e., initial attempt
plus retry attempts) or only the "retry attempts".

To improve the programming model, this commit renames maxAttempts to
maxRetries in @⁠Retryable and RetryPolicy.Builder and renames
RetryPolicy.withMaxAttempts() to RetryPolicy.withMaxRetries(). In
addition, this commit updates the documentation to consistently point
out that total attempts = 1 initial attempt + maxRetries attempts.

Closes gh-35772
This commit is contained in:
Sam Brannen
2025-11-10 11:15:25 +01:00
parent 771517dc36
commit 24590092ef
12 changed files with 134 additions and 97 deletions
@@ -35,7 +35,7 @@ import org.springframework.util.backoff.FixedBackOff;
*
* <p>Also provides factory methods and a fluent builder API for creating retry
* policies with common configurations. See {@link #withDefaults()},
* {@link #withMaxAttempts(long)}, {@link #builder()}, and the configuration
* {@link #withMaxRetries(long)}, {@link #builder()}, and the configuration
* options in {@link Builder} for details.
*
* @author Sam Brannen
@@ -58,12 +58,15 @@ public interface RetryPolicy {
/**
* Get the {@link BackOff} strategy to use for this retry policy.
* <p>Defaults to a fixed backoff of {@value Builder#DEFAULT_DELAY} milliseconds
* and maximum {@value Builder#DEFAULT_MAX_ATTEMPTS} retry attempts.
* and maximum {@value Builder#DEFAULT_MAX_RETRIES} retries.
* <p>Note that {@code total attempts = 1 initial attempt + maxRetries attempts}.
* Thus, when {@code maxRetries} is set to 3, a retryable operation will be
* invoked at least once and at most 4 times.
* @return the {@code BackOff} strategy to use
* @see FixedBackOff
*/
default BackOff getBackOff() {
return new FixedBackOff(Builder.DEFAULT_DELAY, Builder.DEFAULT_MAX_ATTEMPTS);
return new FixedBackOff(Builder.DEFAULT_DELAY, Builder.DEFAULT_MAX_RETRIES);
}
@@ -71,7 +74,10 @@ public interface RetryPolicy {
* Create a {@link RetryPolicy} with default configuration.
* <p>The returned policy applies to all exception types, uses a fixed backoff
* of {@value Builder#DEFAULT_DELAY} milliseconds, and supports maximum
* {@value Builder#DEFAULT_MAX_ATTEMPTS} retry attempts.
* {@value Builder#DEFAULT_MAX_RETRIES} retries.
* <p>Note that {@code total attempts = 1 initial attempt + maxRetries attempts}.
* Thus, when {@code maxRetries} is set to 3, a retryable operation will be
* invoked at least once and at most 4 times.
* @see FixedBackOff
*/
static RetryPolicy withDefaults() {
@@ -80,16 +86,19 @@ public interface RetryPolicy {
/**
* Create a {@link RetryPolicy} configured with a maximum number of retry attempts.
* <p>Note that {@code total attempts = 1 initial attempt + maxRetries attempts}.
* Thus, if {@code maxRetries} is set to 4, a retryable operation will be invoked
* at least once and at most 5 times.
* <p>The returned policy applies to all exception types and uses a fixed backoff
* of {@value Builder#DEFAULT_DELAY} milliseconds.
* @param maxAttempts the maximum number of retry attempts;
* @param maxRetries the maximum number of retry attempts;
* must be positive (or zero for no retry)
* @see Builder#maxAttempts(long)
* @see Builder#maxRetries(long)
* @see FixedBackOff
*/
static RetryPolicy withMaxAttempts(long maxAttempts) {
assertMaxAttemptsIsNotNegative(maxAttempts);
return builder().backOff(new FixedBackOff(Builder.DEFAULT_DELAY, maxAttempts)).build();
static RetryPolicy withMaxRetries(long maxRetries) {
assertMaxRetriesIsNotNegative(maxRetries);
return builder().backOff(new FixedBackOff(Builder.DEFAULT_DELAY, maxRetries)).build();
}
/**
@@ -101,9 +110,9 @@ public interface RetryPolicy {
}
private static void assertMaxAttemptsIsNotNegative(long maxAttempts) {
Assert.isTrue(maxAttempts >= 0,
() -> "Invalid maxAttempts (%d): must be positive or zero for no retry.".formatted(maxAttempts));
private static void assertMaxRetriesIsNotNegative(long maxRetries) {
Assert.isTrue(maxRetries >= 0,
() -> "Invalid maxRetries (%d): must be positive or zero for no retry.".formatted(maxRetries));
}
private static void assertIsNotNegative(String name, Duration duration) {
@@ -124,9 +133,9 @@ public interface RetryPolicy {
final class Builder {
/**
* The default {@linkplain #maxAttempts(long) max attempts}: {@value}.
* The default {@linkplain #maxRetries(long) max retries}: {@value}.
*/
public static final long DEFAULT_MAX_ATTEMPTS = 3;
public static final long DEFAULT_MAX_RETRIES = 3;
/**
* The default {@linkplain #delay(Duration) delay}: {@value} ms.
@@ -147,7 +156,7 @@ public interface RetryPolicy {
private @Nullable BackOff backOff;
private @Nullable Long maxAttempts;
private @Nullable Long maxRetries;
private @Nullable Duration delay;
@@ -174,7 +183,7 @@ public interface RetryPolicy {
* <p>The supplied value will override any previously configured value.
* <p><strong>WARNING</strong>: If you configure a custom {@code BackOff}
* strategy, you should not configure any of the following:
* {@link #maxAttempts(long) maxAttempts}, {@link #delay(Duration) delay},
* {@link #maxRetries(long) maxRetries}, {@link #delay(Duration) delay},
* {@link #jitter(Duration) jitter}, {@link #multiplier(double) multiplier},
* or {@link #maxDelay(Duration) maxDelay}.
* @param backOff the {@code BackOff} strategy
@@ -188,17 +197,20 @@ public interface RetryPolicy {
/**
* Specify the maximum number of retry attempts.
* <p>The default is {@value #DEFAULT_MAX_ATTEMPTS}.
* <p>Note that {@code total attempts = 1 initial attempt + maxRetries attempts}.
* Thus, if {@code maxRetries} is set to 4, a retryable operation will be
* invoked at least once and at most 5 times.
* <p>The default is {@value #DEFAULT_MAX_RETRIES}.
* <p>The supplied value will override any previously configured value.
* <p>You should not specify this configuration option if you have
* configured a custom {@link #backOff(BackOff) BackOff} strategy.
* @param maxAttempts the maximum number of retry attempts;
* @param maxRetries the maximum number of retry attempts;
* must be positive (or zero for no retry)
* @return this {@code Builder} instance for chained method invocations
*/
public Builder maxAttempts(long maxAttempts) {
assertMaxAttemptsIsNotNegative(maxAttempts);
this.maxAttempts = maxAttempts;
public Builder maxRetries(long maxRetries) {
assertMaxRetriesIsNotNegative(maxRetries);
this.maxRetries = maxRetries;
return this;
}
@@ -412,15 +424,15 @@ public interface RetryPolicy {
public RetryPolicy build() {
BackOff backOff = this.backOff;
if (backOff != null) {
boolean misconfigured = (this.maxAttempts != null || this.delay != null || this.jitter != null ||
boolean misconfigured = (this.maxRetries != null || this.delay != null || this.jitter != null ||
this.multiplier != null || this.maxDelay != null);
Assert.state(!misconfigured, """
The following configuration options are not supported with a custom BackOff strategy: \
maxAttempts, delay, jitter, multiplier, or maxDelay.""");
maxRetries, delay, jitter, multiplier, or maxDelay.""");
}
else {
ExponentialBackOff exponentialBackOff = new ExponentialBackOff();
exponentialBackOff.setMaxAttempts(this.maxAttempts != null ? this.maxAttempts : DEFAULT_MAX_ATTEMPTS);
exponentialBackOff.setMaxAttempts(this.maxRetries != null ? this.maxRetries : DEFAULT_MAX_RETRIES);
exponentialBackOff.setInitialInterval(this.delay != null ? this.delay.toMillis() : DEFAULT_DELAY);
exponentialBackOff.setMaxInterval(this.maxDelay != null ? this.maxDelay.toMillis() : DEFAULT_MAX_DELAY);
exponentialBackOff.setMultiplier(this.multiplier != null ? this.multiplier : DEFAULT_MULTIPLIER);
@@ -31,8 +31,8 @@ import org.springframework.util.backoff.BackOffExecution;
* A basic implementation of {@link RetryOperations} that executes and potentially
* retries a {@link Retryable} operation based on a configured {@link RetryPolicy}.
*
* <p>By default, a retryable operation will be retried at most 3 times with a
* fixed backoff of 1 second.
* <p>By default, a retryable operation will be executed once and potentially
* retried at most 3 times with a fixed backoff of 1 second.
*
* <p>A {@link RetryListener} can be {@linkplain #setRetryListener(RetryListener)
* registered} to react to events published during key retry phases (before a
@@ -83,7 +83,7 @@ public class RetryTemplate implements RetryOperations {
* <p>Defaults to {@code RetryPolicy.withDefaults()}.
* @param retryPolicy the retry policy to use
* @see RetryPolicy#withDefaults()
* @see RetryPolicy#withMaxAttempts(long)
* @see RetryPolicy#withMaxRetries(long)
* @see RetryPolicy#builder()
*/
public void setRetryPolicy(RetryPolicy retryPolicy) {
@@ -29,17 +29,17 @@ import static org.springframework.core.retry.RetryPolicy.Builder.DEFAULT_DELAY;
import static org.springframework.util.backoff.BackOffExecution.STOP;
/**
* Max attempts {@link RetryPolicy} tests.
* Max retries {@link RetryPolicy} tests.
*
* @author Mahmoud Ben Hassine
* @author Sam Brannen
* @since 7.0
*/
class MaxAttemptsRetryPolicyTests {
class MaxRetriesRetryPolicyTests {
@Test
void maxAttempts() {
var retryPolicy = RetryPolicy.builder().maxAttempts(2).delay(Duration.ZERO).build();
void maxRetries() {
var retryPolicy = RetryPolicy.builder().maxRetries(2).delay(Duration.ZERO).build();
var backOffExecution = retryPolicy.getBackOff().start();
var throwable = mock(Throwable.class);
@@ -55,8 +55,8 @@ class MaxAttemptsRetryPolicyTests {
}
@Test
void maxAttemptsZero() {
var retryPolicy = RetryPolicy.builder().maxAttempts(0).delay(Duration.ZERO).build();
void maxRetriesZero() {
var retryPolicy = RetryPolicy.builder().maxRetries(0).delay(Duration.ZERO).build();
var backOffExecution = retryPolicy.getBackOff().start();
var throwable = mock(Throwable.class);
@@ -67,9 +67,9 @@ class MaxAttemptsRetryPolicyTests {
}
@Test
void maxAttemptsAndPredicate() {
void maxRetriesAndPredicate() {
var retryPolicy = RetryPolicy.builder()
.maxAttempts(4)
.maxRetries(4)
.delay(Duration.ofMillis(1))
.predicate(NumberFormatException.class::isInstance)
.build();
@@ -94,9 +94,9 @@ class MaxAttemptsRetryPolicyTests {
}
@Test
void maxAttemptsWithIncludesAndExcludes() {
void maxRetriesWithIncludesAndExcludes() {
var retryPolicy = RetryPolicy.builder()
.maxAttempts(6)
.maxRetries(6)
.includes(RuntimeException.class, IOException.class)
.excludes(FileNotFoundException.class, CustomFileSystemException.class)
.build();
@@ -64,15 +64,15 @@ class RetryPolicyTests {
}
@Test
void withMaxAttemptsPreconditions() {
void withMaxRetriesPreconditions() {
assertThatIllegalArgumentException()
.isThrownBy(() -> RetryPolicy.withMaxAttempts(-1))
.withMessageStartingWith("Invalid maxAttempts (-1)");
.isThrownBy(() -> RetryPolicy.withMaxRetries(-1))
.withMessageStartingWith("Invalid maxRetries (-1)");
}
@Test
void withMaxAttempts() {
var policy = RetryPolicy.withMaxAttempts(5);
void withMaxRetries() {
var policy = RetryPolicy.withMaxRetries(5);
assertThat(policy.shouldRetry(new AssertionError())).isTrue();
assertThat(policy.shouldRetry(new IOException())).isTrue();
@@ -96,7 +96,7 @@ class RetryPolicyTests {
.isThrownBy(() -> RetryPolicy.builder().backOff(mock()).delay(Duration.ofMillis(10)).build())
.withMessage("""
The following configuration options are not supported with a custom BackOff strategy: \
maxAttempts, delay, jitter, multiplier, or maxDelay.""");
maxRetries, delay, jitter, multiplier, or maxDelay.""");
}
@Test
@@ -111,15 +111,15 @@ class RetryPolicyTests {
}
@Test
void maxAttemptsPreconditions() {
void maxRetriesPreconditions() {
assertThatIllegalArgumentException()
.isThrownBy(() -> RetryPolicy.builder().maxAttempts(-1))
.withMessageStartingWith("Invalid maxAttempts (-1)");
.isThrownBy(() -> RetryPolicy.builder().maxRetries(-1))
.withMessageStartingWith("Invalid maxRetries (-1)");
}
@Test
void maxAttempts() {
var policy = RetryPolicy.builder().maxAttempts(5).build();
void maxRetries() {
var policy = RetryPolicy.builder().maxRetries(5).build();
assertThat(policy.getBackOff())
.asInstanceOf(type(ExponentialBackOff.class))
@@ -57,7 +57,7 @@ import static org.mockito.Mockito.verifyNoMoreInteractions;
*/
class RetryTemplateTests {
private final RetryPolicy retryPolicy = RetryPolicy.builder().maxAttempts(3).delay(Duration.ZERO).build();
private final RetryPolicy retryPolicy = RetryPolicy.builder().maxRetries(3).delay(Duration.ZERO).build();
private final RetryTemplate retryTemplate = new RetryTemplate(retryPolicy);
@@ -116,7 +116,7 @@ class RetryTemplateTests {
@Test
void retryWithInitialFailureAndZeroRetriesFixedBackOffPolicy() {
RetryPolicy retryPolicy = RetryPolicy.withMaxAttempts(0);
RetryPolicy retryPolicy = RetryPolicy.withMaxRetries(0);
RetryTemplate retryTemplate = new RetryTemplate(retryPolicy);
retryTemplate.setRetryListener(retryListener);
@@ -138,7 +138,7 @@ class RetryTemplateTests {
@Test
void retryWithInitialFailureAndZeroRetriesBackOffPolicyFromBuilder() {
RetryPolicy retryPolicy = RetryPolicy.builder().maxAttempts(0).build();
RetryPolicy retryPolicy = RetryPolicy.builder().maxRetries(0).build();
RetryTemplate retryTemplate = new RetryTemplate(retryPolicy);
retryTemplate.setRetryListener(retryListener);
@@ -263,7 +263,7 @@ class RetryTemplateTests {
};
var retryPolicy = RetryPolicy.builder()
.maxAttempts(5)
.maxRetries(5)
.delay(Duration.ofMillis(1))
.predicate(NumberFormatException.class::isInstance)
.predicate(t -> t.getMessage().equals("Boom!"))
@@ -311,7 +311,7 @@ class RetryTemplateTests {
};
var retryPolicy = RetryPolicy.builder()
.maxAttempts(Integer.MAX_VALUE)
.maxRetries(Integer.MAX_VALUE)
.delay(Duration.ZERO)
.includes(IOException.class)
.build();
@@ -344,13 +344,13 @@ class RetryTemplateTests {
static final List<ArgumentSet> includesAndExcludesRetryPolicies = List.of(
argumentSet("Excludes",
RetryPolicy.builder()
.maxAttempts(Integer.MAX_VALUE)
.maxRetries(Integer.MAX_VALUE)
.delay(Duration.ZERO)
.excludes(FileNotFoundException.class)
.build()),
argumentSet("Includes & Excludes",
RetryPolicy.builder()
.maxAttempts(Integer.MAX_VALUE)
.maxRetries(Integer.MAX_VALUE)
.delay(Duration.ZERO)
.includes(IOException.class)
.excludes(FileNotFoundException.class)