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
This commit is contained in:
Sam Brannen
2025-10-09 16:53:23 +02:00
parent 58940794cf
commit 97ae5fde7c
9 changed files with 140 additions and 44 deletions
@@ -17,7 +17,7 @@
package org.springframework.resilience;
import java.io.IOException;
import java.lang.reflect.Method;
import java.nio.charset.MalformedInputException;
import java.nio.file.AccessDeniedException;
import java.nio.file.FileSystemException;
import java.time.Duration;
@@ -35,7 +35,6 @@ import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.beans.factory.support.RootBeanDefinition;
import org.springframework.resilience.annotation.RetryAnnotationBeanPostProcessor;
import org.springframework.resilience.annotation.Retryable;
import org.springframework.resilience.retry.MethodRetryPredicate;
import org.springframework.resilience.retry.MethodRetrySpec;
import org.springframework.resilience.retry.SimpleRetryInterceptor;
@@ -96,13 +95,16 @@ class ReactiveRetryInterceptorTests {
// Exact includes match: IOException
assertThatRuntimeException()
.isThrownBy(() -> proxy.ioOperation().block())
// Does NOT throw a RetryExhaustedException, because IOException3Predicate
// returns false once the exception's message is "3".
// Does NOT throw a RetryExhaustedException, because RejectMalformedInputException3Predicate
// rejects a retry if the last exception was a MalformedInputException with message "3".
.satisfies(isReactiveException())
.havingCause()
.isInstanceOf(IOException.class)
.withMessage("3");
// 1 initial attempt + 2 retries
.isInstanceOf(MalformedInputException.class)
.withMessageContaining("3");
// 3 = 1 initial invocation + 2 retry attempts
// Not 3 retry attempts, because RejectMalformedInputException3Predicate rejects
// a retry if the last exception was a MalformedInputException with message "3".
assertThat(target.counter.get()).isEqualTo(3);
}
@@ -120,6 +122,22 @@ class ReactiveRetryInterceptorTests {
assertThat(target.counter.get()).isEqualTo(4);
}
@Test // gh-35583
void withPostProcessorForClassWithCauseIncludesMatch() {
AnnotatedClassBean proxy = getProxiedAnnotatedClassBean();
AnnotatedClassBean target = (AnnotatedClassBean) AopProxyUtils.getSingletonTarget(proxy);
// Subtype includes match: FileSystemException
assertThatRuntimeException()
.isThrownBy(() -> proxy.fileSystemOperationWithNestedException().block())
.satisfies(isRetryExhaustedException())
.havingCause()
.isExactlyInstanceOf(RuntimeException.class)
.withCauseExactlyInstanceOf(FileSystemException.class);
// 1 initial attempt + 3 retries
assertThat(target.counter.get()).isEqualTo(4);
}
@Test
void withPostProcessorForClassWithExcludesMatch() {
AnnotatedClassBean proxy = getProxiedAnnotatedClassBean();
@@ -350,7 +368,7 @@ class ReactiveRetryInterceptorTests {
@Retryable(delay = 10, jitter = 5, multiplier = 2.0, maxDelay = 40,
includes = IOException.class, excludes = AccessDeniedException.class,
predicate = IOException3Predicate.class)
predicate = RejectMalformedInputException3Predicate.class)
static class AnnotatedClassBean {
AtomicInteger counter = new AtomicInteger();
@@ -358,6 +376,9 @@ class ReactiveRetryInterceptorTests {
public Mono<Object> ioOperation() {
return Mono.fromCallable(() -> {
counter.incrementAndGet();
if (counter.get() == 3) {
throw new MalformedInputException(counter.get());
}
throw new IOException(counter.toString());
});
}
@@ -369,6 +390,13 @@ class ReactiveRetryInterceptorTests {
});
}
public Mono<Object> fileSystemOperationWithNestedException() {
return Mono.fromCallable(() -> {
counter.incrementAndGet();
throw new RuntimeException(new FileSystemException(counter.toString()));
});
}
public Mono<Object> accessOperation() {
return Mono.fromCallable(() -> {
counter.incrementAndGet();
@@ -393,13 +421,7 @@ class ReactiveRetryInterceptorTests {
}
private static class IOException3Predicate implements MethodRetryPredicate {
@Override
public boolean shouldRetry(Method method, Throwable throwable) {
return !(throwable.getClass() == IOException.class && "3".equals(throwable.getMessage()));
}
}
// Bean classes for boundary testing
@@ -0,0 +1,31 @@
/*
* 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.resilience;
import java.lang.reflect.Method;
import java.nio.charset.MalformedInputException;
import org.springframework.resilience.retry.MethodRetryPredicate;
class RejectMalformedInputException3Predicate implements MethodRetryPredicate {
@Override
public boolean shouldRetry(Method method, Throwable throwable) {
return !(throwable.getClass() == MalformedInputException.class && throwable.getMessage().contains("3"));
}
}
@@ -18,7 +18,7 @@ package org.springframework.resilience;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.charset.MalformedInputException;
import java.nio.file.AccessDeniedException;
import java.time.Duration;
import java.util.Properties;
@@ -42,15 +42,16 @@ import org.springframework.resilience.annotation.ConcurrencyLimit;
import org.springframework.resilience.annotation.EnableResilientMethods;
import org.springframework.resilience.annotation.RetryAnnotationBeanPostProcessor;
import org.springframework.resilience.annotation.Retryable;
import org.springframework.resilience.retry.MethodRetryPredicate;
import org.springframework.resilience.retry.MethodRetrySpec;
import org.springframework.resilience.retry.SimpleRetryInterceptor;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIOException;
import static org.assertj.core.api.Assertions.assertThatRuntimeException;
/**
* @author Juergen Hoeller
* @author Sam Brannen
* @since 7.0
*/
class RetryInterceptorTests {
@@ -187,12 +188,22 @@ class RetryInterceptorTests {
AnnotatedClassBean proxy = bf.getBean(AnnotatedClassBean.class);
AnnotatedClassBean target = (AnnotatedClassBean) AopProxyUtils.getSingletonTarget(proxy);
assertThatIOException().isThrownBy(proxy::retryOperation).withMessage("3");
// 3 = 1 initial invocation + 2 retry attempts
// Not 3 retry attempts, because RejectMalformedInputException3Predicate rejects
// a retry if the last exception was a MalformedInputException with message "3".
assertThatIOException().isThrownBy(proxy::retryOperation).withMessageContaining("3");
assertThat(target.counter).isEqualTo(3);
// 7 = 3 + 1 initial invocation + 3 retry attempts
assertThatRuntimeException()
.isThrownBy(proxy::retryOperationWithNestedException)
.havingCause()
.isExactlyInstanceOf(IOException.class)
.withMessage("7");
assertThat(target.counter).isEqualTo(7);
assertThatIOException().isThrownBy(proxy::otherOperation);
assertThat(target.counter).isEqualTo(4);
assertThat(target.counter).isEqualTo(8);
assertThatIOException().isThrownBy(proxy::overrideOperation);
assertThat(target.counter).isEqualTo(6);
assertThat(target.counter).isEqualTo(10);
}
@Test
@@ -212,7 +223,10 @@ class RetryInterceptorTests {
AnnotatedClassBeanWithStrings proxy = ctx.getBean(AnnotatedClassBeanWithStrings.class);
AnnotatedClassBeanWithStrings target = (AnnotatedClassBeanWithStrings) AopProxyUtils.getSingletonTarget(proxy);
assertThatIOException().isThrownBy(proxy::retryOperation).withMessage("3");
// 3 = 1 initial invocation + 2 retry attempts
// Not 3 retry attempts, because RejectMalformedInputException3Predicate rejects
// a retry if the last exception was a MalformedInputException with message "3".
assertThatIOException().isThrownBy(proxy::retryOperation).withMessageContaining("3");
assertThat(target.counter).isEqualTo(3);
assertThatIOException().isThrownBy(proxy::otherOperation);
assertThat(target.counter).isEqualTo(4);
@@ -237,7 +251,10 @@ class RetryInterceptorTests {
AnnotatedClassBeanWithStrings proxy = ctx.getBean(AnnotatedClassBeanWithStrings.class);
AnnotatedClassBeanWithStrings target = (AnnotatedClassBeanWithStrings) AopProxyUtils.getSingletonTarget(proxy);
assertThatIOException().isThrownBy(proxy::retryOperation).withMessage("3");
// 3 = 1 initial invocation + 2 retry attempts
// Not 3 retry attempts, because RejectMalformedInputException3Predicate rejects
// a retry if the last exception was a MalformedInputException with message "3".
assertThatIOException().isThrownBy(proxy::retryOperation).withMessageContaining("3");
assertThat(target.counter).isEqualTo(3);
assertThatIOException().isThrownBy(proxy::otherOperation);
assertThat(target.counter).isEqualTo(4);
@@ -267,6 +284,7 @@ class RetryInterceptorTests {
int counter = 0;
@Override
public void retryOperation() throws IOException {
counter++;
throw new IOException(Integer.toString(counter));
@@ -314,16 +332,24 @@ class RetryInterceptorTests {
@Retryable(delay = 10, jitter = 5, multiplier = 2.0, maxDelay = 40,
includes = IOException.class, excludes = AccessDeniedException.class,
predicate = CustomPredicate.class)
predicate = RejectMalformedInputException3Predicate.class)
static class AnnotatedClassBean {
int counter = 0;
public void retryOperation() throws IOException {
counter++;
if (counter == 3) {
throw new MalformedInputException(counter);
}
throw new IOException(Integer.toString(counter));
}
public void retryOperationWithNestedException() {
counter++;
throw new RuntimeException(new IOException(Integer.toString(counter)));
}
public void otherOperation() throws IOException {
counter++;
throw new AccessDeniedException(Integer.toString(counter));
@@ -340,13 +366,16 @@ class RetryInterceptorTests {
@Retryable(delayString = "${delay}", jitterString = "${jitter}",
multiplierString = "${multiplier}", maxDelayString = "${maxDelay}",
includes = IOException.class, excludes = AccessDeniedException.class,
predicate = CustomPredicate.class)
predicate = RejectMalformedInputException3Predicate.class)
static class AnnotatedClassBeanWithStrings {
int counter = 0;
public void retryOperation() throws IOException {
counter++;
if (counter == 3) {
throw new MalformedInputException(counter);
}
throw new IOException(Integer.toString(counter));
}
@@ -363,15 +392,6 @@ class RetryInterceptorTests {
}
private static class CustomPredicate implements MethodRetryPredicate {
@Override
public boolean shouldRetry(Method method, Throwable throwable) {
return !"3".equals(throwable.getMessage());
}
}
static class DoubleAnnotatedBean {
AtomicInteger current = new AtomicInteger();