Redesign RetryPolicy to directly incorporate BackOff

After experimenting with our newly introduced core retry support
(RetryPolicy, RetryTemplate, etc.) and @⁠Retryable support, it
became apparent that there are overlapping concerns between the current
RetryPolicy and BackOff contracts.

- RetryPolicy and BackOff both have stateful executions: RetryExecution
  and BackOffExecution. However, only one stateful execution is
  necessary.

- FixedBackOff and ExponentialBackOff already incorporate "retry" logic
  in terms of max attempts, max elapsed time, etc. Thus, there is no
  need to duplicate such behavior in a RetryPolicy and its
  RetryExecution.

- RetryTemplate currently accepts both a RetryPolicy and a BackOff in
  order to instrument the retry algorithm. However, users would
  probably rather focus on configuring all "retry" logic via a single
  mechanism.

In light of the above, this commit directly incorporates BackOff
in RetryPolicy as follows.

- Remove the RetryExecution interface and move its shouldRetry() method
  to RetryPolicy, replacing the current RetryExecution start() method.

- Introduce a default getBackOff() method in the RetryPolicy interface.

- Introduce RetryPolicy.withDefaults() factory method.

- Completely overhaul the RetryPolicy.Builder to provide support for
  configuring a BackOff strategy.

- Remove BackOff configuration from RetryTemplate.

- Revise the method signatures of callbacks in RetryListener.

The collective result of these changes can be witnessed in the
reworked implementation of AbstractRetryInterceptor.

RetryPolicy retryPolicy = RetryPolicy.builder()
		.includes(spec.includes())
		.excludes(spec.excludes())
		.predicate(spec.predicate().forMethod(method))
		.maxAttempts(spec.maxAttempts())
		.delay(Duration.ofMillis(spec.delay()))
		.maxDelay(Duration.ofMillis(spec.maxDelay()))
		.jitter(Duration.ofMillis(spec.jitter()))
		.multiplier(spec.multiplier())
		.build();

RetryTemplate retryTemplate = new RetryTemplate(retryPolicy);

See gh-34716
See gh-34529
See gh-35058
Closes gh-35110
This commit is contained in:
Sam Brannen
2025-06-27 17:28:10 +02:00
parent 5a6c019413
commit bcdf26d492
13 changed files with 994 additions and 527 deletions
@@ -1,188 +0,0 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.core.retry;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.FileSystemException;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.mockito.Mockito.mock;
/**
* Max attempts tests for {@link DefaultRetryPolicy} and its {@link RetryExecution}.
*
* @author Mahmoud Ben Hassine
* @author Sam Brannen
* @since 7.0
*/
class MaxAttemptsDefaultRetryPolicyTests {
@Test
void invalidMaxAttempts() {
assertThatIllegalArgumentException()
.isThrownBy(() -> RetryPolicy.withMaxAttempts(0))
.withMessage("Max attempts must be greater than zero");
assertThatIllegalArgumentException()
.isThrownBy(() -> RetryPolicy.withMaxAttempts(-1))
.withMessage("Max attempts must be greater than zero");
}
@Test
void maxAttempts() {
var retryPolicy = RetryPolicy.withMaxAttempts(2);
var retryExecution = retryPolicy.start();
var throwable = mock(Throwable.class);
assertThat(retryExecution.shouldRetry(throwable)).isTrue();
assertThat(retryExecution.shouldRetry(throwable)).isTrue();
assertThat(retryExecution.shouldRetry(throwable)).isFalse();
assertThat(retryExecution.shouldRetry(throwable)).isFalse();
}
@Test
void maxAttemptsAndPredicate() {
var retryPolicy = RetryPolicy.builder()
.maxAttempts(4)
.predicate(NumberFormatException.class::isInstance)
.build();
var retryExecution = retryPolicy.start();
// 4 retries
assertThat(retryExecution.shouldRetry(new NumberFormatException())).isTrue();
assertThat(retryExecution.shouldRetry(new IllegalStateException())).isFalse();
assertThat(retryExecution.shouldRetry(new IllegalStateException())).isFalse();
assertThat(retryExecution.shouldRetry(new CustomNumberFormatException())).isTrue();
// After policy exhaustion
assertThat(retryExecution.shouldRetry(new NumberFormatException())).isFalse();
assertThat(retryExecution.shouldRetry(new IllegalStateException())).isFalse();
}
@Test
void maxAttemptsWithIncludesAndExcludes() {
var policy = RetryPolicy.builder()
.maxAttempts(6)
.includes(RuntimeException.class, IOException.class)
.excludes(FileNotFoundException.class, CustomFileSystemException.class)
.build();
var retryExecution = policy.start();
// 6 retries
assertThat(retryExecution.shouldRetry(new IOException())).isTrue();
assertThat(retryExecution.shouldRetry(new RuntimeException())).isTrue();
assertThat(retryExecution.shouldRetry(new FileNotFoundException())).isFalse();
assertThat(retryExecution.shouldRetry(new FileSystemException("file"))).isTrue();
assertThat(retryExecution.shouldRetry(new CustomFileSystemException("file"))).isFalse();
assertThat(retryExecution.shouldRetry(new IOException())).isTrue();
// After policy exhaustion
assertThat(retryExecution.shouldRetry(new IOException())).isFalse();
}
@Test
void toStringImplementations() {
var policy = RetryPolicy.builder()
.maxAttempts(6)
.includes(RuntimeException.class, IOException.class)
.excludes(FileNotFoundException.class, CustomFileSystemException.class)
.build();
assertThat(policy).asString().isEqualTo("""
DefaultRetryPolicy[\
maxAttempts=6, \
includes=[java.lang.RuntimeException, java.io.IOException], \
excludes=[java.io.FileNotFoundException, \
org.springframework.core.retry.MaxAttemptsDefaultRetryPolicyTests.CustomFileSystemException]]""");
var template = """
DefaultRetryPolicyExecution[\
maxAttempts=6, \
retryCount=%d, \
includes=[java.lang.RuntimeException, java.io.IOException], \
excludes=[java.io.FileNotFoundException, \
org.springframework.core.retry.MaxAttemptsDefaultRetryPolicyTests.CustomFileSystemException]]""";
var retryExecution = policy.start();
var count = 0;
assertThat(retryExecution).asString().isEqualTo(template, count++);
retryExecution.shouldRetry(new IOException());
assertThat(retryExecution).asString().isEqualTo(template, count++);
retryExecution.shouldRetry(new IOException());
assertThat(retryExecution).asString().isEqualTo(template, count++);
}
@Test
void toStringImplementationsWithPredicateAsClass() {
var policy = RetryPolicy.builder()
.maxAttempts(1)
.predicate(new NumberFormatExceptionMatcher())
.build();
assertThat(policy).asString()
.isEqualTo("DefaultRetryPolicy[maxAttempts=1, predicate=NumberFormatExceptionMatcher]");
var retryExecution = policy.start();
assertThat(retryExecution).asString()
.isEqualTo("DefaultRetryPolicyExecution[maxAttempts=1, retryCount=0, predicate=NumberFormatExceptionMatcher]");
}
@Test
void toStringImplementationsWithPredicateAsLambda() {
var policy = RetryPolicy.builder()
.maxAttempts(2)
.predicate(NumberFormatException.class::isInstance)
.build();
assertThat(policy).asString()
.matches("DefaultRetryPolicy\\[maxAttempts=2, predicate=MaxAttemptsDefaultRetryPolicyTests.+?Lambda.+?]");
var retryExecution = policy.start();
assertThat(retryExecution).asString()
.matches("DefaultRetryPolicyExecution\\[maxAttempts=2, retryCount=0, predicate=MaxAttemptsDefaultRetryPolicyTests.+?Lambda.+?]");
retryExecution.shouldRetry(new NumberFormatException());
assertThat(retryExecution).asString()
.matches("DefaultRetryPolicyExecution\\[maxAttempts=2, retryCount=1, predicate=MaxAttemptsDefaultRetryPolicyTests.+?Lambda.+?]");
retryExecution.shouldRetry(new NumberFormatException());
assertThat(retryExecution).asString()
.matches("DefaultRetryPolicyExecution\\[maxAttempts=2, retryCount=2, predicate=MaxAttemptsDefaultRetryPolicyTests.+?Lambda.+?]");
retryExecution.shouldRetry(new NumberFormatException());
assertThat(retryExecution).asString()
.matches("DefaultRetryPolicyExecution\\[maxAttempts=2, retryCount=3, predicate=MaxAttemptsDefaultRetryPolicyTests.+?Lambda.+?]");
}
@SuppressWarnings("serial")
private static class CustomNumberFormatException extends NumberFormatException {
}
@SuppressWarnings("serial")
private static class CustomFileSystemException extends FileSystemException {
CustomFileSystemException(String file) {
super(file);
}
}
}
@@ -0,0 +1,125 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.core.retry;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.FileSystemException;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.springframework.util.backoff.BackOffExecution.STOP;
/**
* Max attempts {@link RetryPolicy} tests.
*
* @author Mahmoud Ben Hassine
* @author Sam Brannen
* @since 7.0
*/
class MaxAttemptsRetryPolicyTests {
@Test
void maxAttempts() {
var retryPolicy = RetryPolicy.builder().maxAttempts(2).delay(Duration.ofMillis(1)).build();
var backOffExecution = retryPolicy.getBackOff().start();
var throwable = mock(Throwable.class);
assertThat(retryPolicy.shouldRetry(throwable)).isTrue();
assertThat(backOffExecution.nextBackOff()).isGreaterThan(0);
assertThat(retryPolicy.shouldRetry(throwable)).isTrue();
assertThat(backOffExecution.nextBackOff()).isGreaterThan(0);
assertThat(retryPolicy.shouldRetry(throwable)).isTrue();
assertThat(backOffExecution.nextBackOff()).isEqualTo(STOP);
assertThat(retryPolicy.shouldRetry(throwable)).isTrue();
assertThat(backOffExecution.nextBackOff()).isEqualTo(STOP);
}
@Test
void maxAttemptsAndPredicate() {
var retryPolicy = RetryPolicy.builder()
.maxAttempts(4)
.delay(Duration.ofMillis(1))
.predicate(NumberFormatException.class::isInstance)
.build();
var backOffExecution = retryPolicy.getBackOff().start();
// 4 retries
assertThat(retryPolicy.shouldRetry(new NumberFormatException())).isTrue();
assertThat(backOffExecution.nextBackOff()).isGreaterThan(0);
assertThat(retryPolicy.shouldRetry(new IllegalStateException())).isFalse();
assertThat(backOffExecution.nextBackOff()).isGreaterThan(0);
assertThat(retryPolicy.shouldRetry(new IllegalStateException())).isFalse();
assertThat(backOffExecution.nextBackOff()).isGreaterThan(0);
assertThat(retryPolicy.shouldRetry(new CustomNumberFormatException())).isTrue();
assertThat(backOffExecution.nextBackOff()).isGreaterThan(0);
// After policy exhaustion
assertThat(retryPolicy.shouldRetry(new NumberFormatException())).isTrue();
assertThat(backOffExecution.nextBackOff()).isEqualTo(STOP);
assertThat(retryPolicy.shouldRetry(new IllegalStateException())).isFalse();
assertThat(backOffExecution.nextBackOff()).isEqualTo(STOP);
}
@Test
void maxAttemptsWithIncludesAndExcludes() {
var retryPolicy = RetryPolicy.builder()
.maxAttempts(6)
.includes(RuntimeException.class, IOException.class)
.excludes(FileNotFoundException.class, CustomFileSystemException.class)
.build();
var backOffExecution = retryPolicy.getBackOff().start();
// 6 retries
assertThat(retryPolicy.shouldRetry(new IOException())).isTrue();
assertThat(backOffExecution.nextBackOff()).isGreaterThan(0);
assertThat(retryPolicy.shouldRetry(new RuntimeException())).isTrue();
assertThat(backOffExecution.nextBackOff()).isGreaterThan(0);
assertThat(retryPolicy.shouldRetry(new FileNotFoundException())).isFalse();
assertThat(backOffExecution.nextBackOff()).isGreaterThan(0);
assertThat(retryPolicy.shouldRetry(new FileSystemException("file"))).isTrue();
assertThat(backOffExecution.nextBackOff()).isGreaterThan(0);
assertThat(retryPolicy.shouldRetry(new CustomFileSystemException("file"))).isFalse();
assertThat(backOffExecution.nextBackOff()).isGreaterThan(0);
assertThat(retryPolicy.shouldRetry(new IOException())).isTrue();
assertThat(backOffExecution.nextBackOff()).isGreaterThan(0);
// After policy exhaustion
assertThat(retryPolicy.shouldRetry(new IOException())).isTrue();
assertThat(backOffExecution.nextBackOff()).isEqualTo(STOP);
}
@SuppressWarnings("serial")
private static class CustomNumberFormatException extends NumberFormatException {
}
@SuppressWarnings("serial")
private static class CustomFileSystemException extends FileSystemException {
CustomFileSystemException(String file) {
super(file);
}
}
}
@@ -1,65 +0,0 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.core.retry;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import static java.time.Duration.ofSeconds;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
/**
* Max duration tests for {@link DefaultRetryPolicy} and its {@link RetryExecution}.
*
* @author Mahmoud Ben Hassine
* @author Sam Brannen
* @since 7.0
*/
class MaxDurationDefaultRetryPolicyTests {
@Test
void invalidMaxDuration() {
assertThatIllegalArgumentException()
.isThrownBy(() -> RetryPolicy.withMaxDuration(Duration.ZERO))
.withMessage("Max duration must be positive");
assertThatIllegalArgumentException()
.isThrownBy(() -> RetryPolicy.withMaxDuration(ofSeconds(-1)))
.withMessage("Max duration must be positive");
}
@Test
void toStringImplementations() {
var policy1 = RetryPolicy.withMaxDuration(ofSeconds(3));
var policy2 = RetryPolicy.builder()
.maxDuration(ofSeconds(1))
.predicate(new NumberFormatExceptionMatcher())
.build();
assertThat(policy1).asString()
.isEqualTo("DefaultRetryPolicy[maxDuration=3000ms]");
assertThat(policy2).asString()
.isEqualTo("DefaultRetryPolicy[maxDuration=1000ms, predicate=NumberFormatExceptionMatcher]");
assertThat(policy1.start()).asString()
.matches("DefaultRetryPolicyExecution\\[maxDuration=3000ms, retryStartTime=.+]");
assertThat(policy2.start()).asString()
.matches("DefaultRetryPolicyExecution\\[maxDuration=1000ms, retryStartTime=.+, predicate=NumberFormatExceptionMatcher]");
}
}
@@ -0,0 +1,455 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.core.retry;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.FileSystemException;
import java.time.Duration;
import java.util.List;
import java.util.StringJoiner;
import org.assertj.core.api.ThrowingConsumer;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.util.backoff.ExponentialBackOff;
import org.springframework.util.backoff.FixedBackOff;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.assertj.core.api.InstanceOfAssertFactories.type;
import static org.mockito.Mockito.mock;
/**
* Unit tests for {@link RetryPolicy} and its builder.
*
* @author Sam Brannen
* @since 7.0
* @see RetryTemplateTests
*/
class RetryPolicyTests {
@Nested
class FactoryMethodTests {
@Test
void withDefaults() {
var policy = RetryPolicy.withDefaults();
assertThat(policy.shouldRetry(new AssertionError())).isTrue();
assertThat(policy.shouldRetry(new IOException())).isTrue();
assertThat(policy.getBackOff())
.asInstanceOf(type(FixedBackOff.class))
.satisfies(backOff -> {
assertThat(backOff.getMaxAttempts()).isEqualTo(3);
assertThat(backOff.getInterval()).isEqualTo(1000);
});
}
@Test
void withMaxAttemptsPreconditions() {
assertThatIllegalArgumentException()
.isThrownBy(() -> RetryPolicy.withMaxAttempts(0))
.withMessage("Max attempts must be greater than zero");
assertThatIllegalArgumentException()
.isThrownBy(() -> RetryPolicy.withMaxAttempts(-1))
.withMessage("Max attempts must be greater than zero");
}
@Test
void withMaxAttempts() {
var policy = RetryPolicy.withMaxAttempts(5);
assertThat(policy.shouldRetry(new AssertionError())).isTrue();
assertThat(policy.shouldRetry(new IOException())).isTrue();
assertThat(policy.getBackOff())
.asInstanceOf(type(FixedBackOff.class))
.satisfies(backOff -> {
assertThat(backOff.getMaxAttempts()).isEqualTo(5);
assertThat(backOff.getInterval()).isEqualTo(1000);
});
}
@Test
void withMaxElapsedTimePreconditions() {
assertThatIllegalArgumentException()
.isThrownBy(() -> RetryPolicy.withMaxElapsedTime(Duration.ofMillis(0)))
.withMessage("Invalid duration (0ms): max elapsed time must be positive.");
assertThatIllegalArgumentException()
.isThrownBy(() -> RetryPolicy.withMaxElapsedTime(Duration.ofMillis(-1)))
.withMessage("Invalid duration (-1ms): max elapsed time must be positive.");
}
@Test
void withMaxElapsedTime() {
var policy = RetryPolicy.withMaxElapsedTime(Duration.ofMillis(42));
assertThat(policy.shouldRetry(new AssertionError())).isTrue();
assertThat(policy.shouldRetry(new IOException())).isTrue();
assertThat(policy.getBackOff())
.asInstanceOf(type(ExponentialBackOff.class))
.satisfies(hasDefaultMaxAttemptsAndDelay())
.extracting(ExponentialBackOff::getMaxElapsedTime).isEqualTo(42L);
}
}
@Nested
class BuilderTests {
@Test
void backOffPlusConflictingConfig() {
assertThatIllegalStateException()
.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, maxDelay, or maxElapsedTime.""");
}
@Test
void backOff() {
var backOff = new FixedBackOff();
var policy = RetryPolicy.builder().backOff(backOff).build();
assertThat(policy.getBackOff()).isEqualTo(backOff);
assertThat(policy).asString()
.isEqualTo("DefaultRetryPolicy[backOff=FixedBackOff[interval=5000, maxAttempts=unlimited]]");
}
@Test
void maxAttemptsPreconditions() {
assertThatIllegalArgumentException()
.isThrownBy(() -> RetryPolicy.builder().maxAttempts(0))
.withMessage("Max attempts must be greater than zero");
assertThatIllegalArgumentException()
.isThrownBy(() -> RetryPolicy.builder().maxAttempts(-1))
.withMessage("Max attempts must be greater than zero");
}
@Test
void maxAttempts() {
var policy = RetryPolicy.builder().maxAttempts(5).build();
assertThat(policy.getBackOff())
.asInstanceOf(type(ExponentialBackOff.class))
.satisfies(backOff -> {
assertThat(backOff.getMaxAttempts()).isEqualTo(5);
assertThat(backOff.getInitialInterval()).isEqualTo(1000);
});
assertToString(policy, 1000, 0, 1, Long.MAX_VALUE, Long.MAX_VALUE, 5);
}
@Test
void delayPreconditions() {
assertThatIllegalArgumentException()
.isThrownBy(() -> RetryPolicy.builder().delay(Duration.ofMillis(0)))
.withMessage("Invalid duration (0ms): delay must be positive.");
assertThatIllegalArgumentException()
.isThrownBy(() -> RetryPolicy.builder().delay(Duration.ofMillis(-1)))
.withMessage("Invalid duration (-1ms): delay must be positive.");
}
@Test
void delay() {
var policy = RetryPolicy.builder().delay(Duration.ofMillis(42)).build();
assertThat(policy.getBackOff())
.asInstanceOf(type(ExponentialBackOff.class))
.satisfies(backOff -> {
assertThat(backOff.getInitialInterval()).isEqualTo(42);
assertThat(backOff.getMaxAttempts()).isEqualTo(3);
});
assertToString(policy, 42, 0, 1, Long.MAX_VALUE, Long.MAX_VALUE, 3);
}
@Test
void jitterPreconditions() {
assertThatIllegalArgumentException()
.isThrownBy(() -> RetryPolicy.builder().jitter(Duration.ofMillis(-1)))
.withMessage("Invalid jitter (-1ms): must be >= 0.");
}
@Test
void jitter() {
var policy = RetryPolicy.builder().jitter(Duration.ofMillis(42)).build();
assertThat(policy.getBackOff())
.asInstanceOf(type(ExponentialBackOff.class))
.satisfies(hasDefaultMaxAttemptsAndDelay())
.extracting(ExponentialBackOff::getJitter).isEqualTo(42L);
assertToString(policy, 1000, 42, 1, Long.MAX_VALUE, Long.MAX_VALUE, 3);
}
@Test
void multiplierPreconditions() {
String template = """
Invalid multiplier '%s': must be greater than or equal to 1. \
A multiplier of 1 is equivalent to a fixed delay.""";
assertThatIllegalArgumentException()
.isThrownBy(() -> RetryPolicy.builder().multiplier(-1))
.withMessage(template, "-1.0");
assertThatIllegalArgumentException()
.isThrownBy(() -> RetryPolicy.builder().multiplier(0))
.withMessage(template, "0.0");
assertThatIllegalArgumentException()
.isThrownBy(() -> RetryPolicy.builder().multiplier(0.5))
.withMessage(template, "0.5");
}
@Test
void multiplier() {
var policy = RetryPolicy.builder().multiplier(1.5).build();
assertThat(policy.getBackOff())
.asInstanceOf(type(ExponentialBackOff.class))
.satisfies(hasDefaultMaxAttemptsAndDelay())
.extracting(ExponentialBackOff::getMultiplier).isEqualTo(1.5);
assertToString(policy, 1000, 0, 1.5, Long.MAX_VALUE, Long.MAX_VALUE, 3);
}
@Test
void maxDelayPreconditions() {
assertThatIllegalArgumentException()
.isThrownBy(() -> RetryPolicy.builder().maxDelay(Duration.ofMillis(0)))
.withMessage("Invalid duration (0ms): max delay must be positive.");
assertThatIllegalArgumentException()
.isThrownBy(() -> RetryPolicy.builder().maxDelay(Duration.ofMillis(-1)))
.withMessage("Invalid duration (-1ms): max delay must be positive.");
}
@Test
void maxDelay() {
var policy = RetryPolicy.builder().maxDelay(Duration.ofMillis(42)).build();
assertThat(policy.getBackOff())
.asInstanceOf(type(ExponentialBackOff.class))
.satisfies(hasDefaultMaxAttemptsAndDelay())
.extracting(ExponentialBackOff::getMaxInterval).isEqualTo(42L);
assertToString(policy, 1000, 0, 1, 42, Long.MAX_VALUE, 3);
}
@Test
void maxElapsedTimePreconditions() {
assertThatIllegalArgumentException()
.isThrownBy(() -> RetryPolicy.builder().maxElapsedTime(Duration.ofMillis(0)))
.withMessage("Invalid duration (0ms): max elapsed time must be positive.");
assertThatIllegalArgumentException()
.isThrownBy(() -> RetryPolicy.builder().maxElapsedTime(Duration.ofMillis(-1)))
.withMessage("Invalid duration (-1ms): max elapsed time must be positive.");
}
@Test
void maxElapsedTime() {
var policy = RetryPolicy.builder().maxElapsedTime(Duration.ofMillis(42)).build();
assertThat(policy.getBackOff())
.asInstanceOf(type(ExponentialBackOff.class))
.satisfies(hasDefaultMaxAttemptsAndDelay())
.extracting(ExponentialBackOff::getMaxElapsedTime).isEqualTo(42L);
assertToString(policy, 1000, 0, 1, Long.MAX_VALUE, 42, 3);
}
@Test
void includes() {
var policy = RetryPolicy.builder()
.includes(FileNotFoundException.class, IllegalArgumentException.class)
.includes(List.of(NumberFormatException.class, AssertionError.class))
.build();
assertThat(policy.shouldRetry(new FileNotFoundException())).isTrue();
assertThat(policy.shouldRetry(new IllegalArgumentException())).isTrue();
assertThat(policy.shouldRetry(new NumberFormatException())).isTrue();
assertThat(policy.shouldRetry(new AssertionError())).isTrue();
assertThat(policy.shouldRetry(new Throwable())).isFalse();
assertThat(policy.shouldRetry(new FileSystemException("fs"))).isFalse();
assertThat(policy.getBackOff())
.asInstanceOf(type(ExponentialBackOff.class))
.satisfies(hasDefaultMaxAttemptsAndDelay());
String filters = "includes=" + names(FileNotFoundException.class, IllegalArgumentException.class,
NumberFormatException.class, AssertionError.class) + ", ";
assertToString(policy, filters, 1000, 0, 1, Long.MAX_VALUE, Long.MAX_VALUE, 3);
}
@Test
void includesSubtypeMatching() {
var policy = RetryPolicy.builder().includes(IOException.class).build();
assertThat(policy.shouldRetry(new FileNotFoundException())).isTrue();
assertThat(policy.shouldRetry(new FileSystemException("fs"))).isTrue();
assertThat(policy.shouldRetry(new Throwable())).isFalse();
assertThat(policy.shouldRetry(new AssertionError())).isFalse();
assertThat(policy.getBackOff())
.asInstanceOf(type(ExponentialBackOff.class))
.satisfies(hasDefaultMaxAttemptsAndDelay());
assertToString(policy, "includes=[java.io.IOException], ", 1000, 0, 1, Long.MAX_VALUE, Long.MAX_VALUE, 3);
}
@Test
void excludes() {
var policy = RetryPolicy.builder()
.excludes(FileNotFoundException.class, IllegalArgumentException.class)
.excludes(List.of(NumberFormatException.class, AssertionError.class))
.build();
assertThat(policy.shouldRetry(new FileNotFoundException())).isFalse();
assertThat(policy.shouldRetry(new IllegalArgumentException())).isFalse();
assertThat(policy.shouldRetry(new NumberFormatException())).isFalse();
assertThat(policy.shouldRetry(new AssertionError())).isFalse();
assertThat(policy.shouldRetry(new Throwable())).isTrue();
assertThat(policy.shouldRetry(new FileSystemException("fs"))).isTrue();
assertThat(policy.getBackOff())
.asInstanceOf(type(ExponentialBackOff.class))
.satisfies(hasDefaultMaxAttemptsAndDelay());
String filters = "excludes=" + names(FileNotFoundException.class, IllegalArgumentException.class,
NumberFormatException.class, AssertionError.class) + ", ";
assertToString(policy, filters, 1000, 0, 1, Long.MAX_VALUE, Long.MAX_VALUE, 3);
}
@Test
void excludesSubtypeMatching() {
var policy = RetryPolicy.builder().excludes(IOException.class).build();
assertThat(policy.shouldRetry(new IOException("fs"))).isFalse();
assertThat(policy.shouldRetry(new FileNotFoundException())).isFalse();
assertThat(policy.shouldRetry(new FileSystemException("fs"))).isFalse();
assertThat(policy.shouldRetry(new Throwable())).isTrue();
assertThat(policy.shouldRetry(new AssertionError())).isTrue();
assertToString(policy, "excludes=[java.io.IOException], ", 1000, 0, 1, Long.MAX_VALUE, Long.MAX_VALUE, 3);
}
@Test
void predicate() {
var policy = RetryPolicy.builder()
.predicate(new NumberFormatExceptionMatcher())
.build();
assertThat(policy.shouldRetry(new NumberFormatException())).isTrue();
assertThat(policy.shouldRetry(new CustomNumberFormatException())).isTrue();
assertThat(policy.shouldRetry(new Throwable())).isFalse();
assertThat(policy.shouldRetry(new Exception())).isFalse();
assertThat(policy.getBackOff())
.asInstanceOf(type(ExponentialBackOff.class))
.satisfies(hasDefaultMaxAttemptsAndDelay());
assertToString(policy, "predicate=NumberFormatExceptionMatcher, ",
1000, 0, 1, Long.MAX_VALUE, Long.MAX_VALUE, 3);
}
@Test
void predicatesCombined() {
var BOOM = "Boom!";
var policy = RetryPolicy.builder()
.predicate(new NumberFormatExceptionMatcher())
.predicate(throwable -> BOOM.equals(throwable.getMessage()))
.build();
assertThat(policy.shouldRetry(new NumberFormatException(BOOM))).isTrue();
assertThat(policy.shouldRetry(new CustomNumberFormatException(BOOM))).isTrue();
assertThat(policy.shouldRetry(new NumberFormatException())).isFalse();
assertThat(policy.shouldRetry(new CustomNumberFormatException())).isFalse();
assertThat(policy.shouldRetry(new Throwable())).isFalse();
assertThat(policy.shouldRetry(new Exception())).isFalse();
assertThat(policy.getBackOff())
.asInstanceOf(type(ExponentialBackOff.class))
.satisfies(hasDefaultMaxAttemptsAndDelay());
assertThat(policy).asString()
.matches("DefaultRetryPolicy\\[predicate=Predicate.+?Lambda.+?, backOff=ExponentialBackOff\\[.+?]]");
}
private static void assertToString(RetryPolicy policy, long initialInterval, long jitter,
double multiplier, long maxInterval, long maxElapsedTime, int maxAttempts) {
assertToString(policy, "", initialInterval, jitter, multiplier, maxInterval, maxElapsedTime, maxAttempts);
}
private static void assertToString(RetryPolicy policy, String filters, long initialInterval, long jitter,
double multiplier, long maxInterval, long maxElapsedTime, int maxAttempts) {
assertThat(policy).asString()
.isEqualTo("""
DefaultRetryPolicy[%sbackOff=ExponentialBackOff[\
initialInterval=%d, \
jitter=%d, \
multiplier=%s, \
maxInterval=%d, \
maxElapsedTime=%d, \
maxAttempts=%d\
]]""",
filters, initialInterval, jitter, multiplier, maxInterval, maxElapsedTime, maxAttempts);
}
@SafeVarargs
@SuppressWarnings("unchecked")
private static String names(Class<? extends Throwable>... types) {
StringJoiner result = new StringJoiner(", ", "[", "]");
for (Class<? extends Throwable> type : types) {
String name = type.getCanonicalName();
result.add(name != null? name : type.getName());
}
return result.toString();
}
}
private static ThrowingConsumer<? super ExponentialBackOff> hasDefaultMaxAttemptsAndDelay() {
return backOff -> {
assertThat(backOff.getMaxAttempts()).isEqualTo(3);
assertThat(backOff.getInitialInterval()).isEqualTo(1000);
};
}
@SuppressWarnings("serial")
private static class CustomNumberFormatException extends NumberFormatException {
CustomNumberFormatException() {
}
CustomNumberFormatException(String s) {
super(s);
}
}
}
@@ -30,18 +30,17 @@ import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments.ArgumentSet;
import org.junit.jupiter.params.provider.FieldSource;
import org.springframework.util.backoff.FixedBackOff;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.junit.jupiter.params.provider.Arguments.argumentSet;
/**
* Tests for {@link RetryTemplate}.
* Integration tests for {@link RetryTemplate} and {@link RetryPolicy}.
*
* @author Mahmoud Ben Hassine
* @author Sam Brannen
* @since 7.0
* @see RetryPolicyTests
*/
class RetryTemplateTests {
@@ -49,8 +48,13 @@ class RetryTemplateTests {
@BeforeEach
void configureTemplate() {
this.retryTemplate.setBackOffPolicy(new FixedBackOff(Duration.ofMillis(10)));
void configureRetryTemplate() {
var retryPolicy = RetryPolicy.builder()
.maxAttempts(3)
.delay(Duration.ofMillis(1))
.build();
retryTemplate.setRetryPolicy(retryPolicy);
}
@Test
@@ -109,7 +113,7 @@ class RetryTemplateTests {
}
@Test
void retryWithFailingRetryableAndCustomRetryPolicyWithMultiplePredicates() {
void retryWithFailingRetryableAndMultiplePredicates() {
var invocationCount = new AtomicInteger();
var exception = new NumberFormatException("Boom!");
@@ -128,7 +132,7 @@ class RetryTemplateTests {
var retryPolicy = RetryPolicy.builder()
.maxAttempts(5)
.maxDuration(Duration.ofMillis(100))
.delay(Duration.ofMillis(1))
.predicate(NumberFormatException.class::isInstance)
.predicate(t -> t.getMessage().equals("Boom!"))
.build();
@@ -167,6 +171,7 @@ class RetryTemplateTests {
var retryPolicy = RetryPolicy.builder()
.maxAttempts(Integer.MAX_VALUE)
.delay(Duration.ofMillis(1))
.includes(IOException.class)
.build();
@@ -189,11 +194,13 @@ class RetryTemplateTests {
argumentSet("Excludes",
RetryPolicy.builder()
.maxAttempts(Integer.MAX_VALUE)
.delay(Duration.ofMillis(1))
.excludes(FileNotFoundException.class)
.build()),
argumentSet("Includes & Excludes",
RetryPolicy.builder()
.maxAttempts(Integer.MAX_VALUE)
.delay(Duration.ofMillis(1))
.includes(IOException.class)
.excludes(FileNotFoundException.class)
.build())
@@ -201,7 +208,7 @@ class RetryTemplateTests {
@ParameterizedTest
@FieldSource("includesAndExcludesRetryPolicies")
void retryWithIncludesAndExcludesRetryPolicies(RetryPolicy retryPolicy) {
void retryWithExceptionIncludesAndExcludes(RetryPolicy retryPolicy) {
retryTemplate.setRetryPolicy(retryPolicy);
var invocationCount = new AtomicInteger();
@@ -21,8 +21,9 @@ import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.core.retry.RetryExecution;
import org.springframework.core.retry.RetryListener;
import org.springframework.core.retry.RetryPolicy;
import org.springframework.core.retry.Retryable;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
@@ -39,7 +40,8 @@ class CompositeRetryListenerTests {
private final RetryListener listener1 = mock();
private final RetryListener listener2 = mock();
private final RetryListener listener3 = mock();
private final RetryExecution retryExecution = mock();
private final RetryPolicy retryPolicy = mock();
private final Retryable<?> retryable = mock();
private final CompositeRetryListener compositeRetryListener =
new CompositeRetryListener(List.of(listener1, listener2));
@@ -52,41 +54,41 @@ class CompositeRetryListenerTests {
@Test
void beforeRetry() {
compositeRetryListener.beforeRetry(retryExecution);
compositeRetryListener.beforeRetry(retryPolicy, retryable);
verify(listener1).beforeRetry(retryExecution);
verify(listener2).beforeRetry(retryExecution);
verify(listener3).beforeRetry(retryExecution);
verify(listener1).beforeRetry(retryPolicy, retryable);
verify(listener2).beforeRetry(retryPolicy, retryable);
verify(listener3).beforeRetry(retryPolicy, retryable);
}
@Test
void onRetrySuccess() {
Object result = new Object();
compositeRetryListener.onRetrySuccess(retryExecution, result);
compositeRetryListener.onRetrySuccess(retryPolicy, retryable, result);
verify(listener1).onRetrySuccess(retryExecution, result);
verify(listener2).onRetrySuccess(retryExecution, result);
verify(listener3).onRetrySuccess(retryExecution, result);
verify(listener1).onRetrySuccess(retryPolicy, retryable, result);
verify(listener2).onRetrySuccess(retryPolicy, retryable, result);
verify(listener3).onRetrySuccess(retryPolicy, retryable, result);
}
@Test
void onRetryFailure() {
Exception exception = new Exception();
compositeRetryListener.onRetryFailure(retryExecution, exception);
compositeRetryListener.onRetryFailure(retryPolicy, retryable, exception);
verify(listener1).onRetryFailure(retryExecution, exception);
verify(listener2).onRetryFailure(retryExecution, exception);
verify(listener3).onRetryFailure(retryExecution, exception);
verify(listener1).onRetryFailure(retryPolicy, retryable, exception);
verify(listener2).onRetryFailure(retryPolicy, retryable, exception);
verify(listener3).onRetryFailure(retryPolicy, retryable, exception);
}
@Test
void onRetryPolicyExhaustion() {
Exception exception = new Exception();
compositeRetryListener.onRetryPolicyExhaustion(retryExecution, exception);
compositeRetryListener.onRetryPolicyExhaustion(retryPolicy, retryable, exception);
verify(listener1).onRetryPolicyExhaustion(retryExecution, exception);
verify(listener2).onRetryPolicyExhaustion(retryExecution, exception);
verify(listener3).onRetryPolicyExhaustion(retryExecution, exception);
verify(listener1).onRetryPolicyExhaustion(retryPolicy, retryable, exception);
verify(listener2).onRetryPolicyExhaustion(retryPolicy, retryable, exception);
verify(listener3).onRetryPolicyExhaustion(retryPolicy, retryable, exception);
}
}