Support timeouts in @​Retryable and RetryPolicy

Specifically, this commit introduces:

- timeout and timeoutString attributes in @​Retryable

- a default getTimeout() method in RetryPolicy

- a timeout() method in RetryPolicy.Builder

- an onRetryPolicyTimeout() callback in RetryListener

- support for checking exceeded timeouts in RetryTemplate (also used
  for imperative method invocations with @​Retryable)

- support for checking exceeded timeouts in reactive pipelines with
  @​Retryable

Closes gh-35963
This commit is contained in:
Sam Brannen
2025-12-09 14:06:35 +01:00
parent ab33000750
commit 9f1d9fe82c
14 changed files with 613 additions and 6 deletions
@@ -21,9 +21,11 @@ import java.nio.charset.MalformedInputException;
import java.nio.file.AccessDeniedException;
import java.nio.file.FileSystemException;
import java.time.Duration;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
import org.assertj.core.api.ThrowingConsumer;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import reactor.core.Exceptions;
import reactor.core.publisher.Flux;
@@ -316,6 +318,83 @@ class ReactiveRetryInterceptorTests {
}
@Nested
class TimeoutTests {
private final AnnotatedMethodBean proxy = getProxiedAnnotatedMethodBean();
private final AnnotatedMethodBean target = (AnnotatedMethodBean) AopProxyUtils.getSingletonTarget(proxy);
@Test
void timeoutNotExceededAfterInitialSuccess() {
String result = proxy.retryOperationWithTimeoutNotExceededAfterInitialSuccess().block();
assertThat(result).isEqualTo("success");
// 1 initial attempt + 0 retries
assertThat(target.counter).hasValue(1);
}
@Test
void timeoutNotExceededAndRetriesExhausted() {
assertThatIllegalStateException()
.isThrownBy(() -> proxy.retryOperationWithTimeoutNotExceededAndRetriesExhausted().block())
.satisfies(isRetryExhaustedException())
.havingCause()
.isInstanceOf(IOException.class)
.withMessage("4");
// 1 initial attempt + 3 retries
assertThat(target.counter).hasValue(4);
}
@Test
void timeoutExceededAfterInitialFailure() {
assertThatRuntimeException()
.isThrownBy(() -> proxy.retryOperationWithTimeoutExceededAfterInitialFailure().block())
.satisfies(isReactiveException())
.havingCause()
.isInstanceOf(TimeoutException.class)
.withMessageContaining("within 5ms");
// 1 initial attempt + 0 retries
assertThat(target.counter).hasValue(1);
}
@Test
void timeoutExceededAfterFirstDelayButBeforeFirstRetry() {
assertThatRuntimeException()
.isThrownBy(() -> proxy.retryOperationWithTimeoutExceededAfterFirstDelayButBeforeFirstRetry().block())
.satisfies(isReactiveException())
.havingCause()
.isInstanceOf(TimeoutException.class)
.withMessageContaining("within 5ms");
// 1 initial attempt + 0 retries
assertThat(target.counter).hasValue(1);
}
@Test
void timeoutExceededAfterFirstRetry() {
assertThatRuntimeException()
.isThrownBy(() -> proxy.retryOperationWithTimeoutExceededAfterFirstRetry().block())
.satisfies(isReactiveException())
.havingCause()
.isInstanceOf(TimeoutException.class)
.withMessageContaining("within 5ms");
// 1 initial attempt + 1 retry
assertThat(target.counter).hasValue(2);
}
@Test
void timeoutExceededAfterSecondRetry() {
assertThatRuntimeException()
.isThrownBy(() -> proxy.retryOperationWithTimeoutExceededAfterSecondRetry().block())
.satisfies(isReactiveException())
.havingCause()
.isInstanceOf(TimeoutException.class)
.withMessageContaining("within 5ms");
// 1 initial attempt + 2 retries
assertThat(target.counter).hasValue(3);
}
}
private static ThrowingConsumer<? super Throwable> isReactiveException() {
return ex -> assertThat(ex.getClass().getName()).isEqualTo("reactor.core.Exceptions$ReactiveException");
}
@@ -368,6 +447,61 @@ class ReactiveRetryInterceptorTests {
throw new IOException(counter.toString());
});
}
@Retryable(timeout = 555, delay = 10)
public Mono<String> retryOperationWithTimeoutNotExceededAfterInitialSuccess() {
return Mono.fromCallable(() -> {
counter.incrementAndGet();
return "success";
});
}
@Retryable(timeout = 555, delay = 10)
public Mono<Object> retryOperationWithTimeoutNotExceededAndRetriesExhausted() {
return Mono.fromCallable(() -> {
counter.incrementAndGet();
throw new IOException(counter.toString());
});
}
@Retryable(timeout = 5, delay = 10)
public Mono<Object> retryOperationWithTimeoutExceededAfterInitialFailure() {
return Mono.fromCallable(() -> {
counter.incrementAndGet();
Thread.sleep(10);
throw new IOException(counter.toString());
});
}
@Retryable(timeout = 5, delay = 10)
public Mono<Object> retryOperationWithTimeoutExceededAfterFirstDelayButBeforeFirstRetry() {
return Mono.fromCallable(() -> {
counter.incrementAndGet();
throw new IOException(counter.toString());
});
}
@Retryable(timeout = 5, delay = 0)
public Mono<Object> retryOperationWithTimeoutExceededAfterFirstRetry() {
return Mono.fromCallable(() -> {
counter.incrementAndGet();
if (counter.get() == 2) {
Thread.sleep(10);
}
throw new IOException(counter.toString());
});
}
@Retryable(timeout = 5, delay = 0)
public Mono<Object> retryOperationWithTimeoutExceededAfterSecondRetry() {
return Mono.fromCallable(() -> {
counter.incrementAndGet();
if (counter.get() == 3) {
Thread.sleep(10);
}
throw new IOException(counter.toString());
});
}
}
@@ -27,6 +27,7 @@ import java.util.concurrent.CompletionException;
import java.util.concurrent.atomic.AtomicInteger;
import org.aopalliance.intercept.MethodInterceptor;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.aop.config.AopConfigUtils;
@@ -312,6 +313,69 @@ class RetryInterceptorTests {
}
@Nested
class TimeoutTests {
private final DefaultListableBeanFactory bf = createBeanFactoryFor(AnnotatedMethodBean.class);
private final AnnotatedMethodBean proxy = bf.getBean(AnnotatedMethodBean.class);
private final AnnotatedMethodBean target = (AnnotatedMethodBean) AopProxyUtils.getSingletonTarget(proxy);
@Test
void timeoutNotExceededAfterInitialSuccess() {
String result = proxy.retryOperationWithTimeoutNotExceededAfterInitialSuccess();
assertThat(result).isEqualTo("success");
// 1 initial attempt + 0 retries
assertThat(target.counter).isEqualTo(1);
}
@Test
void timeoutNotExceededAndRetriesExhausted() {
assertThatIOException()
.isThrownBy(proxy::retryOperationWithTimeoutNotExceededAndRetriesExhausted)
.withMessage("4");
// 1 initial attempt + 3 retries
assertThat(target.counter).isEqualTo(4);
}
@Test
void timeoutExceededAfterInitialFailure() {
assertThatIOException()
.isThrownBy(proxy::retryOperationWithTimeoutExceededAfterInitialFailure)
.withMessage("1");
// 1 initial attempt + 0 retries
assertThat(target.counter).isEqualTo(1);
}
@Test
void timeoutExceededAfterFirstDelayButBeforeFirstRetry() {
assertThatIOException()
.isThrownBy(proxy::retryOperationWithTimeoutExceededAfterFirstDelayButBeforeFirstRetry)
.withMessage("1");
// 1 initial attempt + 0 retries
assertThat(target.counter).isEqualTo(1);
}
@Test
void timeoutExceededAfterFirstRetry() {
assertThatIOException()
.isThrownBy(proxy::retryOperationWithTimeoutExceededAfterFirstRetry)
.withMessage("2");
// 1 initial attempt + 1 retry
assertThat(target.counter).isEqualTo(2);
}
@Test
void timeoutExceededAfterSecondRetry() {
assertThatIOException()
.isThrownBy(proxy::retryOperationWithTimeoutExceededAfterSecondRetry)
.withMessage("3");
// 1 initial attempt + 2 retries
assertThat(target.counter).isEqualTo(3);
}
}
private static DefaultListableBeanFactory createBeanFactoryFor(Class<?> beanClass) {
DefaultListableBeanFactory bf = new DefaultListableBeanFactory();
bf.registerBeanDefinition("bean", new RootBeanDefinition(beanClass));
@@ -349,6 +413,49 @@ class RetryInterceptorTests {
counter++;
throw new IOException(Integer.toString(counter));
}
@Retryable(timeout = 555, delay = 10)
public String retryOperationWithTimeoutNotExceededAfterInitialSuccess() {
counter++;
return "success";
}
@Retryable(timeout = 555, delay = 10)
public void retryOperationWithTimeoutNotExceededAndRetriesExhausted() throws Exception {
counter++;
throw new IOException(Integer.toString(counter));
}
@Retryable(timeout = 5, delay = 10)
public void retryOperationWithTimeoutExceededAfterInitialFailure() throws Exception {
counter++;
Thread.sleep(10);
throw new IOException(Integer.toString(counter));
}
@Retryable(timeout = 5, delay = 10)
public void retryOperationWithTimeoutExceededAfterFirstDelayButBeforeFirstRetry() throws IOException {
counter++;
throw new IOException(Integer.toString(counter));
}
@Retryable(timeout = 5, delay = 0)
public void retryOperationWithTimeoutExceededAfterFirstRetry() throws Exception {
counter++;
if (counter == 2) {
Thread.sleep(10);
}
throw new IOException(Integer.toString(counter));
}
@Retryable(timeout = 5, delay = 0)
public void retryOperationWithTimeoutExceededAfterSecondRetry() throws Exception {
counter++;
if (counter == 3) {
Thread.sleep(10);
}
throw new IOException(Integer.toString(counter));
}
}