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 17:55:03 +02:00
parent 58940794cf
commit 97ae5fde7c
9 changed files with 140 additions and 44 deletions
@@ -58,7 +58,7 @@ class DefaultRetryPolicy implements RetryPolicy {
@Override
public boolean shouldRetry(Throwable throwable) {
return (this.exceptionFilter.match(throwable) &&
return (this.exceptionFilter.match(throwable, true) &&
(this.predicate == null || this.predicate.test(throwable)));
}
@@ -296,6 +296,9 @@ public interface RetryPolicy {
* Specify the types of exceptions for which the {@link RetryPolicy}
* should retry a failed operation.
* <p>Defaults to all exception types.
* <p>The supplied exception types will be matched against an exception
* thrown by a failed operation as well as nested
* {@linkplain Throwable#getCause() causes}.
* <p>If included exception types have already been configured, the supplied
* types will be added to the existing list of included types.
* <p>This can be combined with other {@code includes}, {@code excludes},
@@ -318,6 +321,9 @@ public interface RetryPolicy {
* Specify the types of exceptions for which the {@link RetryPolicy}
* should retry a failed operation.
* <p>Defaults to all exception types.
* <p>The supplied exception types will be matched against an exception
* thrown by a failed operation as well as nested
* {@linkplain Throwable#getCause() causes}.
* <p>If included exception types have already been configured, the supplied
* types will be added to the existing list of included types.
* <p>This can be combined with other {@code includes}, {@code excludes},
@@ -337,6 +343,9 @@ public interface RetryPolicy {
/**
* Specify the types of exceptions for which the {@link RetryPolicy}
* should not retry a failed operation.
* <p>The supplied exception types will be matched against an exception
* thrown by a failed operation as well as nested
* {@linkplain Throwable#getCause() causes}.
* <p>If excluded exception types have already been configured, the supplied
* types will be added to the existing list of excluded types.
* <p>This can be combined with {@code includes}, other {@code excludes},
@@ -358,6 +367,9 @@ public interface RetryPolicy {
/**
* Specify the types of exceptions for which the {@link RetryPolicy}
* should not retry a failed operation.
* <p>The supplied exception types will be matched against an exception
* thrown by a failed operation as well as nested
* {@linkplain Throwable#getCause() causes}.
* <p>If excluded exception types have already been configured, the supplied
* types will be added to the existing list of excluded types.
* <p>This can be combined with {@code includes}, other {@code excludes},
@@ -369,7 +369,7 @@ class RetryTemplateTests {
public String execute() throws Exception {
return switch (invocationCount.incrementAndGet()) {
case 1 -> throw new IOException();
case 2 -> throw new IOException();
case 2 -> throw new RuntimeException(new IOException());
case 3 -> throw new CustomFileNotFoundException();
default -> "success";
};
@@ -388,14 +388,15 @@ class RetryTemplateTests {
.withCauseExactlyInstanceOf(CustomFileNotFoundException.class)
.satisfies(hasSuppressedExceptionsSatisfyingExactly(
suppressed1 -> assertThat(suppressed1).isExactlyInstanceOf(IOException.class),
suppressed2 -> assertThat(suppressed2).isExactlyInstanceOf(IOException.class)
suppressed2 -> assertThat(suppressed2).isExactlyInstanceOf(RuntimeException.class)
.hasCauseExactlyInstanceOf(IOException.class)
))
.satisfies(throwable -> assertThat(throwable.getRetryCount()).isEqualTo(2))
.satisfies(throwable -> {
repeat(2, () -> {
inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable);
inOrder.verify(retryListener).onRetryFailure(eq(retryPolicy), eq(retryable), any(IOException.class));
});
inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable);
inOrder.verify(retryListener).onRetryFailure(eq(retryPolicy), eq(retryable), any(RuntimeException.class));
inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable);
inOrder.verify(retryListener).onRetryFailure(eq(retryPolicy), eq(retryable), any(CustomFileNotFoundException.class));
inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, throwable);
});
// 3 = 1 initial invocation + 2 retry attempts