Skip to content

Commit 9f1d9fe

Browse files
committed
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
1 parent ab33000 commit 9f1d9fe

File tree

14 files changed

+613
-6
lines changed

14 files changed

+613
-6
lines changed

spring-context/src/main/java/org/springframework/resilience/annotation/RetryAnnotationBeanPostProcessor.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ private class RetryAnnotationInterceptor extends AbstractRetryInterceptor {
9999
Arrays.asList(retryable.includes()), Arrays.asList(retryable.excludes()),
100100
instantiatePredicate(retryable.predicate()),
101101
parseLong(retryable.maxRetries(), retryable.maxRetriesString()),
102+
parseDuration(retryable.timeout(), retryable.timeoutString(), timeUnit),
102103
parseDuration(retryable.delay(), retryable.delayString(), timeUnit),
103104
parseDuration(retryable.jitter(), retryable.jitterString(), timeUnit),
104105
parseDouble(retryable.multiplier(), retryable.multiplierString()),

spring-context/src/main/java/org/springframework/resilience/annotation/Retryable.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,39 @@
122122
*/
123123
String maxRetriesString() default "";
124124

125+
/**
126+
* The maximum amount of elapsed time allowed for the initial invocation and
127+
* any subsequent retry attempts, including delays.
128+
* <p>The default is {@code 0}, which signals that no timeout should be applied.
129+
* <p>The time unit is milliseconds by default but can be overridden via
130+
* {@link #timeUnit}.
131+
* <p>Must be greater than or equal to zero.
132+
* @since 7.0.2
133+
*/
134+
long timeout() default 0;
135+
136+
/**
137+
* The timeout, as a duration String.
138+
* <p>A non-empty value specified here overrides the {@link #timeout()} attribute.
139+
* <p>The duration String can be in several formats:
140+
* <ul>
141+
* <li>a plain integer &mdash; which is interpreted to represent a duration in
142+
* milliseconds by default unless overridden via {@link #timeUnit()} (prefer
143+
* using {@link #delay()} in that case)</li>
144+
* <li>any of the known {@link org.springframework.format.annotation.DurationFormat.Style
145+
* DurationFormat.Style}: the {@link org.springframework.format.annotation.DurationFormat.Style#ISO8601 ISO8601}
146+
* style or the {@link org.springframework.format.annotation.DurationFormat.Style#SIMPLE SIMPLE} style
147+
* &mdash; using the {@link #timeUnit()} as fallback if the string doesn't contain an explicit unit</li>
148+
* <li>one of the above, with Spring-style "${...}" placeholders as well as SpEL expressions</li>
149+
* </ul>
150+
* @return the timeout as a String value &mdash; for example, a placeholder, a
151+
* {@link org.springframework.format.annotation.DurationFormat.Style#ISO8601 java.time.Duration} compliant value,
152+
* or a {@link org.springframework.format.annotation.DurationFormat.Style#SIMPLE simple format} compliant value
153+
* @since 7.0.2
154+
* @see #timeout()
155+
*/
156+
String timeoutString() default "";
157+
125158
/**
126159
* The base delay after the initial invocation. If a multiplier is specified,
127160
* this serves as the initial delay to multiply from.

spring-context/src/main/java/org/springframework/resilience/retry/AbstractRetryInterceptor.java

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package org.springframework.resilience.retry;
1818

1919
import java.lang.reflect.Method;
20+
import java.time.Duration;
2021
import java.util.concurrent.Future;
2122

2223
import org.aopalliance.intercept.MethodInterceptor;
@@ -94,6 +95,7 @@ public AbstractRetryInterceptor() {
9495
.excludes(spec.excludes())
9596
.predicate(spec.predicate().forMethod(method))
9697
.maxRetries(spec.maxRetries())
98+
.timeout(spec.timeout())
9799
.delay(spec.delay())
98100
.jitter(spec.jitter())
99101
.multiplier(spec.multiplier())
@@ -142,8 +144,20 @@ public static Object adaptReactiveResult(
142144
.multiplier(spec.multiplier())
143145
.maxBackoff(spec.maxDelay())
144146
.filter(spec.combinedPredicate().forMethod(method));
145-
publisher = (adapter.isMultiValue() ? Flux.from(publisher).retryWhen(retry) :
146-
Mono.from(publisher).retryWhen(retry));
147+
148+
Duration timeout = spec.timeout();
149+
boolean timeoutIsPositive = (!timeout.isNegative() && !timeout.isZero());
150+
if (adapter.isMultiValue()) {
151+
publisher = (timeoutIsPositive ?
152+
Flux.from(publisher).retryWhen(retry).timeout(timeout) :
153+
Flux.from(publisher).retryWhen(retry));
154+
}
155+
else {
156+
publisher = (timeoutIsPositive ?
157+
Mono.from(publisher).retryWhen(retry).timeout(timeout) :
158+
Mono.from(publisher).retryWhen(retry));
159+
}
160+
147161
return adapter.fromPublisher(publisher);
148162
}
149163

spring-context/src/main/java/org/springframework/resilience/retry/MethodRetrySpec.java

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,14 @@
2828
* on {@link org.springframework.resilience.annotation.Retryable}.
2929
*
3030
* @author Juergen Hoeller
31+
* @author Sam Brannen
3132
* @since 7.0
3233
* @param includes applicable exception types to attempt a retry for
3334
* @param excludes non-applicable exception types to avoid a retry for
3435
* @param predicate a predicate for filtering exceptions from applicable methods
3536
* @param maxRetries the maximum number of retry attempts
37+
* @param timeout the maximum amount of elapsed time allowed for the initial
38+
* invocation and any subsequent retry attempts, including delays
3639
* @param delay the base delay after the initial invocation
3740
* @param jitter a jitter value for the next retry attempt
3841
* @param multiplier a multiplier for a delay for the next retry attempt
@@ -46,20 +49,40 @@ public record MethodRetrySpec(
4649
Collection<Class<? extends Throwable>> excludes,
4750
MethodRetryPredicate predicate,
4851
long maxRetries,
52+
Duration timeout,
4953
Duration delay,
5054
Duration jitter,
5155
double multiplier,
5256
Duration maxDelay) {
5357

58+
/**
59+
* Construct a new {@code MethodRetryPredicate} with the supplied arguments.
60+
*/
5461
public MethodRetrySpec(MethodRetryPredicate predicate, long maxRetries, Duration delay) {
5562
this(predicate, maxRetries, delay, Duration.ZERO, 1.0, Duration.ofMillis(Long.MAX_VALUE));
5663
}
5764

65+
/**
66+
* Construct a new {@code MethodRetryPredicate} with the supplied arguments.
67+
*/
5868
public MethodRetrySpec(MethodRetryPredicate predicate, long maxRetries, Duration delay,
5969
Duration jitter, double multiplier, Duration maxDelay) {
6070

61-
this(Collections.emptyList(), Collections.emptyList(), predicate, maxRetries, delay,
62-
jitter, multiplier, maxDelay);
71+
this(Collections.emptyList(), Collections.emptyList(), predicate, maxRetries, Duration.ZERO,
72+
delay, jitter, multiplier, maxDelay);
73+
}
74+
75+
/**
76+
* Construct a new {@code MethodRetryPredicate} with the supplied arguments.
77+
* @deprecated as of Spring Framework 7.0.2, in favor of
78+
* {@link #MethodRetrySpec(Collection, Collection, MethodRetryPredicate, long, Duration, Duration, Duration, double, Duration)}
79+
*/
80+
@Deprecated(since = "7.0.2", forRemoval = true)
81+
public MethodRetrySpec(Collection<Class<? extends Throwable>> includes,
82+
Collection<Class<? extends Throwable>> excludes, MethodRetryPredicate predicate,
83+
long maxRetries, Duration delay, Duration jitter, double multiplier, Duration maxDelay) {
84+
85+
this(includes, excludes, predicate, maxRetries, Duration.ZERO, delay, jitter, multiplier, maxDelay);
6386
}
6487

6588

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

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@
2121
import java.nio.file.AccessDeniedException;
2222
import java.nio.file.FileSystemException;
2323
import java.time.Duration;
24+
import java.util.concurrent.TimeoutException;
2425
import java.util.concurrent.atomic.AtomicInteger;
2526

2627
import org.assertj.core.api.ThrowingConsumer;
28+
import org.junit.jupiter.api.Nested;
2729
import org.junit.jupiter.api.Test;
2830
import reactor.core.Exceptions;
2931
import reactor.core.publisher.Flux;
@@ -316,6 +318,83 @@ void adaptReactiveResultWithAlwaysFailingOperation() {
316318
}
317319

318320

321+
@Nested
322+
class TimeoutTests {
323+
324+
private final AnnotatedMethodBean proxy = getProxiedAnnotatedMethodBean();
325+
private final AnnotatedMethodBean target = (AnnotatedMethodBean) AopProxyUtils.getSingletonTarget(proxy);
326+
327+
@Test
328+
void timeoutNotExceededAfterInitialSuccess() {
329+
String result = proxy.retryOperationWithTimeoutNotExceededAfterInitialSuccess().block();
330+
assertThat(result).isEqualTo("success");
331+
// 1 initial attempt + 0 retries
332+
assertThat(target.counter).hasValue(1);
333+
}
334+
335+
@Test
336+
void timeoutNotExceededAndRetriesExhausted() {
337+
assertThatIllegalStateException()
338+
.isThrownBy(() -> proxy.retryOperationWithTimeoutNotExceededAndRetriesExhausted().block())
339+
.satisfies(isRetryExhaustedException())
340+
.havingCause()
341+
.isInstanceOf(IOException.class)
342+
.withMessage("4");
343+
// 1 initial attempt + 3 retries
344+
assertThat(target.counter).hasValue(4);
345+
}
346+
347+
@Test
348+
void timeoutExceededAfterInitialFailure() {
349+
assertThatRuntimeException()
350+
.isThrownBy(() -> proxy.retryOperationWithTimeoutExceededAfterInitialFailure().block())
351+
.satisfies(isReactiveException())
352+
.havingCause()
353+
.isInstanceOf(TimeoutException.class)
354+
.withMessageContaining("within 5ms");
355+
// 1 initial attempt + 0 retries
356+
assertThat(target.counter).hasValue(1);
357+
}
358+
359+
@Test
360+
void timeoutExceededAfterFirstDelayButBeforeFirstRetry() {
361+
assertThatRuntimeException()
362+
.isThrownBy(() -> proxy.retryOperationWithTimeoutExceededAfterFirstDelayButBeforeFirstRetry().block())
363+
.satisfies(isReactiveException())
364+
.havingCause()
365+
.isInstanceOf(TimeoutException.class)
366+
.withMessageContaining("within 5ms");
367+
// 1 initial attempt + 0 retries
368+
assertThat(target.counter).hasValue(1);
369+
}
370+
371+
@Test
372+
void timeoutExceededAfterFirstRetry() {
373+
assertThatRuntimeException()
374+
.isThrownBy(() -> proxy.retryOperationWithTimeoutExceededAfterFirstRetry().block())
375+
.satisfies(isReactiveException())
376+
.havingCause()
377+
.isInstanceOf(TimeoutException.class)
378+
.withMessageContaining("within 5ms");
379+
// 1 initial attempt + 1 retry
380+
assertThat(target.counter).hasValue(2);
381+
}
382+
383+
@Test
384+
void timeoutExceededAfterSecondRetry() {
385+
assertThatRuntimeException()
386+
.isThrownBy(() -> proxy.retryOperationWithTimeoutExceededAfterSecondRetry().block())
387+
.satisfies(isReactiveException())
388+
.havingCause()
389+
.isInstanceOf(TimeoutException.class)
390+
.withMessageContaining("within 5ms");
391+
// 1 initial attempt + 2 retries
392+
assertThat(target.counter).hasValue(3);
393+
}
394+
395+
}
396+
397+
319398
private static ThrowingConsumer<? super Throwable> isReactiveException() {
320399
return ex -> assertThat(ex.getClass().getName()).isEqualTo("reactor.core.Exceptions$ReactiveException");
321400
}
@@ -368,6 +447,61 @@ public Mono<Object> retryOperation() {
368447
throw new IOException(counter.toString());
369448
});
370449
}
450+
451+
@Retryable(timeout = 555, delay = 10)
452+
public Mono<String> retryOperationWithTimeoutNotExceededAfterInitialSuccess() {
453+
return Mono.fromCallable(() -> {
454+
counter.incrementAndGet();
455+
return "success";
456+
});
457+
}
458+
459+
@Retryable(timeout = 555, delay = 10)
460+
public Mono<Object> retryOperationWithTimeoutNotExceededAndRetriesExhausted() {
461+
return Mono.fromCallable(() -> {
462+
counter.incrementAndGet();
463+
throw new IOException(counter.toString());
464+
});
465+
}
466+
467+
@Retryable(timeout = 5, delay = 10)
468+
public Mono<Object> retryOperationWithTimeoutExceededAfterInitialFailure() {
469+
return Mono.fromCallable(() -> {
470+
counter.incrementAndGet();
471+
Thread.sleep(10);
472+
throw new IOException(counter.toString());
473+
});
474+
}
475+
476+
@Retryable(timeout = 5, delay = 10)
477+
public Mono<Object> retryOperationWithTimeoutExceededAfterFirstDelayButBeforeFirstRetry() {
478+
return Mono.fromCallable(() -> {
479+
counter.incrementAndGet();
480+
throw new IOException(counter.toString());
481+
});
482+
}
483+
484+
@Retryable(timeout = 5, delay = 0)
485+
public Mono<Object> retryOperationWithTimeoutExceededAfterFirstRetry() {
486+
return Mono.fromCallable(() -> {
487+
counter.incrementAndGet();
488+
if (counter.get() == 2) {
489+
Thread.sleep(10);
490+
}
491+
throw new IOException(counter.toString());
492+
});
493+
}
494+
495+
@Retryable(timeout = 5, delay = 0)
496+
public Mono<Object> retryOperationWithTimeoutExceededAfterSecondRetry() {
497+
return Mono.fromCallable(() -> {
498+
counter.incrementAndGet();
499+
if (counter.get() == 3) {
500+
Thread.sleep(10);
501+
}
502+
throw new IOException(counter.toString());
503+
});
504+
}
371505
}
372506

373507

0 commit comments

Comments
 (0)