Skip to content

Commit 97ae5fd

Browse files
committed
Match against exception causes in @⁠Retryable and RetryPolicy
Prior to this commit, our @⁠Retryable support as well as a RetryPolicy created by the RetryPolicy.Builder only matched against top-level exceptions when filtering included/excluded exceptions thrown by a @⁠Retryable method or Retryable operation. With this commit, we now match against not only top-level exceptions but also nested causes within those top-level exceptions. This is achieved via the new ExceptionTypeFilter.match(Throwable, boolean) support. See gh-35592 Closes gh-35583
1 parent 5894079 commit 97ae5fd

File tree

9 files changed

+140
-44
lines changed

9 files changed

+140
-44
lines changed

framework-docs/modules/ROOT/pages/core/resilience.adoc

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,10 @@ public void sendNotification() {
2626
By default, the method invocation will be retried for any exception thrown: with at most 3
2727
retry attempts after an initial failure, and a delay of 1 second between attempts.
2828

29-
This can be specifically adapted for every method if necessary – for example, by narrowing
30-
the exceptions to retry:
29+
This can be specifically adapted for every method if necessary — for example, by narrowing
30+
the exceptions to retry via the `includes` and `excludes` attributes. The supplied
31+
exception types will be matched against an exception thrown by a failed invocation as well
32+
as nested causes.
3133

3234
[source,java,indent=0,subs="verbatim,quotes"]
3335
----
@@ -182,7 +184,8 @@ If you only need to customize the number of retry attempts, you can use the
182184
<1> Explicitly uses `RetryPolicy.withMaxAttempts(5)`.
183185

184186
If you need to narrow the types of exceptions to retry, that can be achieved via the
185-
`includes()` and `excludes()` builder methods.
187+
`includes()` and `excludes()` builder methods. The supplied exception types will be
188+
matched against an exception thrown by a failed operation as well as nested causes.
186189

187190
[source,java,indent=0,subs="verbatim,quotes"]
188191
----
@@ -204,7 +207,7 @@ If you need to narrow the types of exceptions to retry, that can be achieved via
204207
For advanced use cases, you can specify a custom `Predicate<Throwable>` via the
205208
`predicate()` method in the `RetryPolicy.Builder`, and the predicate will be used to
206209
determine whether to retry a failed operation based on a given `Throwable` – for example,
207-
by checking the cause or the message of the `Throwable`.
210+
by checking the message of the `Throwable`.
208211
209212
Custom predicates can be combined with `includes` and `excludes`; however, custom
210213
predicates will always be applied after `includes` and `excludes` have been applied.

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
* project but redesigned as a minimal core retry feature in the Spring Framework.
4141
*
4242
* @author Juergen Hoeller
43+
* @author Sam Brannen
4344
* @since 7.0
4445
* @see EnableResilientMethods
4546
* @see RetryAnnotationBeanPostProcessor
@@ -64,6 +65,9 @@
6465
/**
6566
* Applicable exception types to attempt a retry for. This attribute
6667
* allows for the convenient specification of assignable exception types.
68+
* <p>The supplied exception types will be matched against an exception
69+
* thrown by a failed invocation as well as nested
70+
* {@linkplain Throwable#getCause() causes}.
6771
* <p>This can optionally be combined with {@link #excludes() excludes} or
6872
* a custom {@link #predicate() predicate}.
6973
* <p>The default is empty, leading to a retry attempt for any exception.
@@ -76,6 +80,9 @@
7680
/**
7781
* Non-applicable exception types to avoid a retry for. This attribute
7882
* allows for the convenient specification of assignable exception types.
83+
* <p>The supplied exception types will be matched against an exception
84+
* thrown by a failed invocation as well as nested
85+
* {@linkplain Throwable#getCause() causes}.
7986
* <p>This can optionally be combined with {@link #includes() includes} or
8087
* a custom {@link #predicate() predicate}.
8188
* <p>The default is empty, leading to a retry attempt for any exception.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ public MethodRetrySpec(MethodRetryPredicate predicate, long maxAttempts, Duratio
6565

6666
MethodRetryPredicate combinedPredicate() {
6767
ExceptionTypeFilter exceptionFilter = new ExceptionTypeFilter(this.includes, this.excludes);
68-
return (method, throwable) -> exceptionFilter.match(throwable) &&
68+
return (method, throwable) -> exceptionFilter.match(throwable, true) &&
6969
this.predicate.shouldRetry(method, throwable);
7070
}
7171

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

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

1919
import java.io.IOException;
20-
import java.lang.reflect.Method;
20+
import java.nio.charset.MalformedInputException;
2121
import java.nio.file.AccessDeniedException;
2222
import java.nio.file.FileSystemException;
2323
import java.time.Duration;
@@ -35,7 +35,6 @@
3535
import org.springframework.beans.factory.support.RootBeanDefinition;
3636
import org.springframework.resilience.annotation.RetryAnnotationBeanPostProcessor;
3737
import org.springframework.resilience.annotation.Retryable;
38-
import org.springframework.resilience.retry.MethodRetryPredicate;
3938
import org.springframework.resilience.retry.MethodRetrySpec;
4039
import org.springframework.resilience.retry.SimpleRetryInterceptor;
4140

@@ -96,13 +95,16 @@ void withPostProcessorForClassWithExactIncludesMatch() {
9695
// Exact includes match: IOException
9796
assertThatRuntimeException()
9897
.isThrownBy(() -> proxy.ioOperation().block())
99-
// Does NOT throw a RetryExhaustedException, because IOException3Predicate
100-
// returns false once the exception's message is "3".
98+
// Does NOT throw a RetryExhaustedException, because RejectMalformedInputException3Predicate
99+
// rejects a retry if the last exception was a MalformedInputException with message "3".
101100
.satisfies(isReactiveException())
102101
.havingCause()
103-
.isInstanceOf(IOException.class)
104-
.withMessage("3");
105-
// 1 initial attempt + 2 retries
102+
.isInstanceOf(MalformedInputException.class)
103+
.withMessageContaining("3");
104+
105+
// 3 = 1 initial invocation + 2 retry attempts
106+
// Not 3 retry attempts, because RejectMalformedInputException3Predicate rejects
107+
// a retry if the last exception was a MalformedInputException with message "3".
106108
assertThat(target.counter.get()).isEqualTo(3);
107109
}
108110

@@ -120,6 +122,22 @@ void withPostProcessorForClassWithSubtypeIncludesMatch() {
120122
assertThat(target.counter.get()).isEqualTo(4);
121123
}
122124

125+
@Test // gh-35583
126+
void withPostProcessorForClassWithCauseIncludesMatch() {
127+
AnnotatedClassBean proxy = getProxiedAnnotatedClassBean();
128+
AnnotatedClassBean target = (AnnotatedClassBean) AopProxyUtils.getSingletonTarget(proxy);
129+
130+
// Subtype includes match: FileSystemException
131+
assertThatRuntimeException()
132+
.isThrownBy(() -> proxy.fileSystemOperationWithNestedException().block())
133+
.satisfies(isRetryExhaustedException())
134+
.havingCause()
135+
.isExactlyInstanceOf(RuntimeException.class)
136+
.withCauseExactlyInstanceOf(FileSystemException.class);
137+
// 1 initial attempt + 3 retries
138+
assertThat(target.counter.get()).isEqualTo(4);
139+
}
140+
123141
@Test
124142
void withPostProcessorForClassWithExcludesMatch() {
125143
AnnotatedClassBean proxy = getProxiedAnnotatedClassBean();
@@ -350,14 +368,17 @@ public Mono<Object> retryOperation() {
350368

351369
@Retryable(delay = 10, jitter = 5, multiplier = 2.0, maxDelay = 40,
352370
includes = IOException.class, excludes = AccessDeniedException.class,
353-
predicate = IOException3Predicate.class)
371+
predicate = RejectMalformedInputException3Predicate.class)
354372
static class AnnotatedClassBean {
355373

356374
AtomicInteger counter = new AtomicInteger();
357375

358376
public Mono<Object> ioOperation() {
359377
return Mono.fromCallable(() -> {
360378
counter.incrementAndGet();
379+
if (counter.get() == 3) {
380+
throw new MalformedInputException(counter.get());
381+
}
361382
throw new IOException(counter.toString());
362383
});
363384
}
@@ -369,6 +390,13 @@ public Mono<Object> fileSystemOperation() {
369390
});
370391
}
371392

393+
public Mono<Object> fileSystemOperationWithNestedException() {
394+
return Mono.fromCallable(() -> {
395+
counter.incrementAndGet();
396+
throw new RuntimeException(new FileSystemException(counter.toString()));
397+
});
398+
}
399+
372400
public Mono<Object> accessOperation() {
373401
return Mono.fromCallable(() -> {
374402
counter.incrementAndGet();
@@ -393,13 +421,7 @@ public Flux<Object> overrideOperation() {
393421
}
394422

395423

396-
private static class IOException3Predicate implements MethodRetryPredicate {
397424

398-
@Override
399-
public boolean shouldRetry(Method method, Throwable throwable) {
400-
return !(throwable.getClass() == IOException.class && "3".equals(throwable.getMessage()));
401-
}
402-
}
403425

404426

405427
// Bean classes for boundary testing
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Copyright 2002-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.resilience;
18+
19+
import java.lang.reflect.Method;
20+
import java.nio.charset.MalformedInputException;
21+
22+
import org.springframework.resilience.retry.MethodRetryPredicate;
23+
24+
class RejectMalformedInputException3Predicate implements MethodRetryPredicate {
25+
26+
@Override
27+
public boolean shouldRetry(Method method, Throwable throwable) {
28+
return !(throwable.getClass() == MalformedInputException.class && throwable.getMessage().contains("3"));
29+
}
30+
31+
}

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

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
import java.io.IOException;
2020
import java.lang.reflect.InvocationTargetException;
21-
import java.lang.reflect.Method;
21+
import java.nio.charset.MalformedInputException;
2222
import java.nio.file.AccessDeniedException;
2323
import java.time.Duration;
2424
import java.util.Properties;
@@ -42,15 +42,16 @@
4242
import org.springframework.resilience.annotation.EnableResilientMethods;
4343
import org.springframework.resilience.annotation.RetryAnnotationBeanPostProcessor;
4444
import org.springframework.resilience.annotation.Retryable;
45-
import org.springframework.resilience.retry.MethodRetryPredicate;
4645
import org.springframework.resilience.retry.MethodRetrySpec;
4746
import org.springframework.resilience.retry.SimpleRetryInterceptor;
4847

4948
import static org.assertj.core.api.Assertions.assertThat;
5049
import static org.assertj.core.api.Assertions.assertThatIOException;
50+
import static org.assertj.core.api.Assertions.assertThatRuntimeException;
5151

5252
/**
5353
* @author Juergen Hoeller
54+
* @author Sam Brannen
5455
* @since 7.0
5556
*/
5657
class RetryInterceptorTests {
@@ -187,12 +188,22 @@ void withPostProcessorForClass() {
187188
AnnotatedClassBean proxy = bf.getBean(AnnotatedClassBean.class);
188189
AnnotatedClassBean target = (AnnotatedClassBean) AopProxyUtils.getSingletonTarget(proxy);
189190

190-
assertThatIOException().isThrownBy(proxy::retryOperation).withMessage("3");
191+
// 3 = 1 initial invocation + 2 retry attempts
192+
// Not 3 retry attempts, because RejectMalformedInputException3Predicate rejects
193+
// a retry if the last exception was a MalformedInputException with message "3".
194+
assertThatIOException().isThrownBy(proxy::retryOperation).withMessageContaining("3");
191195
assertThat(target.counter).isEqualTo(3);
196+
// 7 = 3 + 1 initial invocation + 3 retry attempts
197+
assertThatRuntimeException()
198+
.isThrownBy(proxy::retryOperationWithNestedException)
199+
.havingCause()
200+
.isExactlyInstanceOf(IOException.class)
201+
.withMessage("7");
202+
assertThat(target.counter).isEqualTo(7);
192203
assertThatIOException().isThrownBy(proxy::otherOperation);
193-
assertThat(target.counter).isEqualTo(4);
204+
assertThat(target.counter).isEqualTo(8);
194205
assertThatIOException().isThrownBy(proxy::overrideOperation);
195-
assertThat(target.counter).isEqualTo(6);
206+
assertThat(target.counter).isEqualTo(10);
196207
}
197208

198209
@Test
@@ -212,7 +223,10 @@ void withPostProcessorForClassWithStrings() {
212223
AnnotatedClassBeanWithStrings proxy = ctx.getBean(AnnotatedClassBeanWithStrings.class);
213224
AnnotatedClassBeanWithStrings target = (AnnotatedClassBeanWithStrings) AopProxyUtils.getSingletonTarget(proxy);
214225

215-
assertThatIOException().isThrownBy(proxy::retryOperation).withMessage("3");
226+
// 3 = 1 initial invocation + 2 retry attempts
227+
// Not 3 retry attempts, because RejectMalformedInputException3Predicate rejects
228+
// a retry if the last exception was a MalformedInputException with message "3".
229+
assertThatIOException().isThrownBy(proxy::retryOperation).withMessageContaining("3");
216230
assertThat(target.counter).isEqualTo(3);
217231
assertThatIOException().isThrownBy(proxy::otherOperation);
218232
assertThat(target.counter).isEqualTo(4);
@@ -237,7 +251,10 @@ void withPostProcessorForClassWithZeroAttempts() {
237251
AnnotatedClassBeanWithStrings proxy = ctx.getBean(AnnotatedClassBeanWithStrings.class);
238252
AnnotatedClassBeanWithStrings target = (AnnotatedClassBeanWithStrings) AopProxyUtils.getSingletonTarget(proxy);
239253

240-
assertThatIOException().isThrownBy(proxy::retryOperation).withMessage("3");
254+
// 3 = 1 initial invocation + 2 retry attempts
255+
// Not 3 retry attempts, because RejectMalformedInputException3Predicate rejects
256+
// a retry if the last exception was a MalformedInputException with message "3".
257+
assertThatIOException().isThrownBy(proxy::retryOperation).withMessageContaining("3");
241258
assertThat(target.counter).isEqualTo(3);
242259
assertThatIOException().isThrownBy(proxy::otherOperation);
243260
assertThat(target.counter).isEqualTo(4);
@@ -267,6 +284,7 @@ static class NonAnnotatedBean implements PlainInterface {
267284

268285
int counter = 0;
269286

287+
@Override
270288
public void retryOperation() throws IOException {
271289
counter++;
272290
throw new IOException(Integer.toString(counter));
@@ -314,16 +332,24 @@ interface AnnotatedInterface {
314332

315333
@Retryable(delay = 10, jitter = 5, multiplier = 2.0, maxDelay = 40,
316334
includes = IOException.class, excludes = AccessDeniedException.class,
317-
predicate = CustomPredicate.class)
335+
predicate = RejectMalformedInputException3Predicate.class)
318336
static class AnnotatedClassBean {
319337

320338
int counter = 0;
321339

322340
public void retryOperation() throws IOException {
323341
counter++;
342+
if (counter == 3) {
343+
throw new MalformedInputException(counter);
344+
}
324345
throw new IOException(Integer.toString(counter));
325346
}
326347

348+
public void retryOperationWithNestedException() {
349+
counter++;
350+
throw new RuntimeException(new IOException(Integer.toString(counter)));
351+
}
352+
327353
public void otherOperation() throws IOException {
328354
counter++;
329355
throw new AccessDeniedException(Integer.toString(counter));
@@ -340,13 +366,16 @@ public void overrideOperation() throws IOException {
340366
@Retryable(delayString = "${delay}", jitterString = "${jitter}",
341367
multiplierString = "${multiplier}", maxDelayString = "${maxDelay}",
342368
includes = IOException.class, excludes = AccessDeniedException.class,
343-
predicate = CustomPredicate.class)
369+
predicate = RejectMalformedInputException3Predicate.class)
344370
static class AnnotatedClassBeanWithStrings {
345371

346372
int counter = 0;
347373

348374
public void retryOperation() throws IOException {
349375
counter++;
376+
if (counter == 3) {
377+
throw new MalformedInputException(counter);
378+
}
350379
throw new IOException(Integer.toString(counter));
351380
}
352381

@@ -363,15 +392,6 @@ public void overrideOperation() throws IOException {
363392
}
364393

365394

366-
private static class CustomPredicate implements MethodRetryPredicate {
367-
368-
@Override
369-
public boolean shouldRetry(Method method, Throwable throwable) {
370-
return !"3".equals(throwable.getMessage());
371-
}
372-
}
373-
374-
375395
static class DoubleAnnotatedBean {
376396

377397
AtomicInteger current = new AtomicInteger();

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ class DefaultRetryPolicy implements RetryPolicy {
5858

5959
@Override
6060
public boolean shouldRetry(Throwable throwable) {
61-
return (this.exceptionFilter.match(throwable) &&
61+
return (this.exceptionFilter.match(throwable, true) &&
6262
(this.predicate == null || this.predicate.test(throwable)));
6363
}
6464

0 commit comments

Comments
 (0)