diff --git a/spring-context/src/main/java/org/springframework/resilience/annotation/RetryAnnotationBeanPostProcessor.java b/spring-context/src/main/java/org/springframework/resilience/annotation/RetryAnnotationBeanPostProcessor.java index b30b11d36f7..4535d0ac9b1 100644 --- a/spring-context/src/main/java/org/springframework/resilience/annotation/RetryAnnotationBeanPostProcessor.java +++ b/spring-context/src/main/java/org/springframework/resilience/annotation/RetryAnnotationBeanPostProcessor.java @@ -30,6 +30,8 @@ import org.springframework.aop.framework.autoproxy.AbstractBeanFactoryAwareAdvis import org.springframework.aop.support.ComposablePointcut; import org.springframework.aop.support.DefaultPointcutAdvisor; import org.springframework.aop.support.annotation.AnnotationMatchingPointcut; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationEventPublisherAware; import org.springframework.context.EmbeddedValueResolverAware; import org.springframework.core.MethodClassKey; import org.springframework.core.annotation.AnnotatedElementUtils; @@ -52,7 +54,9 @@ import org.springframework.util.StringValueResolver; */ @SuppressWarnings("serial") public class RetryAnnotationBeanPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor - implements EmbeddedValueResolverAware { + implements ApplicationEventPublisherAware, EmbeddedValueResolverAware { + + private final RetryAnnotationInterceptor interceptor = new RetryAnnotationInterceptor(); private @Nullable StringValueResolver embeddedValueResolver; @@ -62,9 +66,7 @@ public class RetryAnnotationBeanPostProcessor extends AbstractBeanFactoryAwareAd Pointcut cpc = new AnnotationMatchingPointcut(Retryable.class, true); Pointcut mpc = new AnnotationMatchingPointcut(null, Retryable.class, true); - this.advisor = new DefaultPointcutAdvisor( - new ComposablePointcut(cpc).union(mpc), - new RetryAnnotationInterceptor()); + this.advisor = new DefaultPointcutAdvisor(new ComposablePointcut(cpc).union(mpc), this.interceptor); } @@ -73,6 +75,11 @@ public class RetryAnnotationBeanPostProcessor extends AbstractBeanFactoryAwareAd this.embeddedValueResolver = resolver; } + @Override + public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) { + this.interceptor.setApplicationEventPublisher(applicationEventPublisher); + } + private class RetryAnnotationInterceptor extends AbstractRetryInterceptor { diff --git a/spring-context/src/main/java/org/springframework/resilience/annotation/Retryable.java b/spring-context/src/main/java/org/springframework/resilience/annotation/Retryable.java index 6e556ecbafe..f86d69d6cc4 100644 --- a/spring-context/src/main/java/org/springframework/resilience/annotation/Retryable.java +++ b/spring-context/src/main/java/org/springframework/resilience/annotation/Retryable.java @@ -34,7 +34,10 @@ import org.springframework.resilience.retry.MethodRetryPredicate; * *
Aligned with {@link org.springframework.core.retry.RetryTemplate} * as well as Reactor's retry support, either re-invoking an imperative - * target method or decorating a reactive result accordingly. + * target method or decorating a returned reactive publisher accordingly. + * + *
For tracking the exceptions encountered by method-level retry processing, + * consider a {@link org.springframework.resilience.retry.MethodRetryEvent} listener. * *
Inspired by the Spring Retry
* project but redesigned as a minimal core retry feature in the Spring Framework.
diff --git a/spring-context/src/main/java/org/springframework/resilience/retry/AbstractRetryInterceptor.java b/spring-context/src/main/java/org/springframework/resilience/retry/AbstractRetryInterceptor.java
index 6b3bc7f40a5..365ac18aeec 100644
--- a/spring-context/src/main/java/org/springframework/resilience/retry/AbstractRetryInterceptor.java
+++ b/spring-context/src/main/java/org/springframework/resilience/retry/AbstractRetryInterceptor.java
@@ -31,10 +31,14 @@ import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import org.springframework.aop.ProxyMethodInvocation;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.context.ApplicationEventPublisherAware;
import org.springframework.core.ReactiveAdapter;
import org.springframework.core.ReactiveAdapterRegistry;
import org.springframework.core.retry.RetryException;
+import org.springframework.core.retry.RetryListener;
import org.springframework.core.retry.RetryPolicy;
+import org.springframework.core.retry.RetryState;
import org.springframework.core.retry.RetryTemplate;
import org.springframework.core.retry.Retryable;
import org.springframework.util.ClassUtils;
@@ -50,7 +54,7 @@ import org.springframework.util.ClassUtils;
* @see Mono#retryWhen
* @see Flux#retryWhen
*/
-public abstract class AbstractRetryInterceptor implements MethodInterceptor {
+public abstract class AbstractRetryInterceptor implements MethodInterceptor, ApplicationEventPublisherAware {
private static final Log logger = LogFactory.getLog(AbstractRetryInterceptor.class);
@@ -62,6 +66,8 @@ public abstract class AbstractRetryInterceptor implements MethodInterceptor {
private final @Nullable ReactiveAdapterRegistry reactiveAdapterRegistry;
+ private @Nullable ApplicationEventPublisher applicationEventPublisher;
+
public AbstractRetryInterceptor() {
if (REACTIVE_STREAMS_PRESENT) {
@@ -72,6 +78,11 @@ public abstract class AbstractRetryInterceptor implements MethodInterceptor {
}
}
+ @Override
+ public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
+ this.applicationEventPublisher = applicationEventPublisher;
+ }
+
@Override
public @Nullable Object invoke(MethodInvocation invocation) throws Throwable {
@@ -90,7 +101,7 @@ public abstract class AbstractRetryInterceptor implements MethodInterceptor {
if (result == null) {
return null;
}
- return ReactorDelegate.adaptReactiveResult(result, adapter, spec, method);
+ return new ReactorDelegate().adaptReactiveResult(invocation, result, adapter, spec);
}
}
@@ -105,7 +116,17 @@ public abstract class AbstractRetryInterceptor implements MethodInterceptor {
.multiplier(spec.multiplier())
.maxDelay(spec.maxDelay())
.build();
+
RetryTemplate retryTemplate = new RetryTemplate(retryPolicy);
+ retryTemplate.setRetryListener(new RetryListener() {
+ @Override
+ public void onRetryableExecution(RetryPolicy retryPolicy, Retryable> retryable, RetryState retryState) {
+ if (!retryState.isSuccessful()) {
+ onEvent(new MethodRetryEvent(invocation, retryState.getLastException(), false));
+ }
+ }
+ });
+
String methodName = ClassUtils.getQualifiedMethodName(method, (target != null ? target.getClass() : null));
try {
@@ -122,13 +143,21 @@ public abstract class AbstractRetryInterceptor implements MethodInterceptor {
});
}
catch (RetryException ex) {
+ onEvent(new MethodRetryEvent(invocation, ex, true));
if (logger.isDebugEnabled()) {
- logger.debug("@Retryable operation '%s' failed".formatted(methodName), ex);
+ logger.debug("Retryable operation '%s' failed".formatted(methodName), ex);
}
throw ex.getCause();
}
}
+ private void onEvent(MethodRetryEvent event) {
+ logger.trace(event, event.getFailure());
+ if (this.applicationEventPublisher != null) {
+ this.applicationEventPublisher.publishEvent(event);
+ }
+ }
+
/**
* Determine the retry specification for the given method on the given target.
* @param method the currently executing method
@@ -141,29 +170,39 @@ public abstract class AbstractRetryInterceptor implements MethodInterceptor {
/**
* Inner class to avoid a hard dependency on Reactive Streams and Reactor at runtime.
*/
- private static class ReactorDelegate {
+ private class ReactorDelegate {
- public static Object adaptReactiveResult(
- Object result, ReactiveAdapter adapter, MethodRetrySpec spec, Method method) {
+ public Object adaptReactiveResult(
+ MethodInvocation invocation, Object result, ReactiveAdapter adapter, MethodRetrySpec spec) {
Publisher> publisher = adapter.toPublisher(result);
Retry retry = Retry.backoff(spec.maxRetries(), spec.delay())
.jitter(calculateJitterFactor(spec))
.multiplier(spec.multiplier())
.maxBackoff(spec.maxDelay())
- .filter(spec.combinedPredicate().forMethod(method));
+ .filter(spec.combinedPredicate().forMethod(invocation.getMethod()));
Duration timeout = spec.timeout();
boolean timeoutIsPositive = (!timeout.isNegative() && !timeout.isZero());
if (adapter.isMultiValue()) {
- publisher = (timeoutIsPositive ?
- Flux.from(publisher).retryWhen(retry).timeout(timeout) :
- Flux.from(publisher).retryWhen(retry));
+ Flux> flux = Flux.from(publisher)
+ .doOnError(ex -> onEvent(new MethodRetryEvent(invocation, ex, false)))
+ .retryWhen(retry);
+ if (timeoutIsPositive) {
+ flux = flux.timeout(timeout);
+ }
+ flux = flux.doOnError(ex -> onEvent(new MethodRetryEvent(invocation, ex, true)));
+ publisher = flux;
}
else {
- publisher = (timeoutIsPositive ?
- Mono.from(publisher).retryWhen(retry).timeout(timeout) :
- Mono.from(publisher).retryWhen(retry));
+ Mono> mono = Mono.from(publisher)
+ .doOnError(ex -> onEvent(new MethodRetryEvent(invocation, ex, false)))
+ .retryWhen(retry);
+ if (timeoutIsPositive) {
+ mono = mono.timeout(timeout);
+ }
+ mono = mono.doOnError(ex -> onEvent(new MethodRetryEvent(invocation, ex, true)));
+ publisher = mono;
}
return adapter.fromPublisher(publisher);
diff --git a/spring-context/src/main/java/org/springframework/resilience/retry/MethodRetryEvent.java b/spring-context/src/main/java/org/springframework/resilience/retry/MethodRetryEvent.java
new file mode 100644
index 00000000000..8bbc3d1c097
--- /dev/null
+++ b/spring-context/src/main/java/org/springframework/resilience/retry/MethodRetryEvent.java
@@ -0,0 +1,110 @@
+/*
+ * 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.retry;
+
+import java.lang.reflect.Method;
+
+import org.aopalliance.intercept.MethodInvocation;
+
+import org.springframework.context.ApplicationEvent;
+import org.springframework.util.ClassUtils;
+
+/**
+ * Event published for every exception encountered during retryable method invocation.
+ * Can be listened to via an {@code ApplicationListener This may be an exception thrown by the method or emitted by the reactive
+ * publisher returned from the method, or a terminal exception on retry
+ * exhaustion, interruption or timeout.
+ * For {@link org.springframework.core.retry.RetryTemplate} executions,
+ * an {@code instanceof RetryException} check identifies a final exception.
+ * For Reactor pipelines, {@code Exceptions.isRetryExhausted} identifies an
+ * exhaustion exception, whereas {@code instanceof TimeoutException} reveals
+ * a timeout scenario.
+ * @see #isRetryAborted()
+ * @see org.springframework.core.retry.RetryException
+ * @see reactor.core.Exceptions#isRetryExhausted
+ * @see java.util.concurrent.TimeoutException
+ */
+ public Throwable getFailure() {
+ return this.failure;
+ }
+
+ /**
+ * Return whether the current failure led to the retry execution getting aborted,
+ * typically indicating exhaustion, interruption or a timeout scenario.
+ * If this returns {@code true}, {@link #getFailure()} exposes the final exception
+ * thrown by the retry infrastructure (rather than thrown by the method itself).
+ * @see #getFailure()
+ */
+ public boolean isRetryAborted() {
+ return this.retryAborted;
+ }
+
+
+ @Override
+ public String toString() {
+ return "MethodRetryEvent: " + ClassUtils.getQualifiedMethodName(getMethod()) + " [" + getFailure() + "]";
+ }
+
+}
diff --git a/spring-context/src/test/java/org/springframework/resilience/MethodRetryEventListener.java b/spring-context/src/test/java/org/springframework/resilience/MethodRetryEventListener.java
new file mode 100644
index 00000000000..df92db6ff71
--- /dev/null
+++ b/spring-context/src/test/java/org/springframework/resilience/MethodRetryEventListener.java
@@ -0,0 +1,38 @@
+/*
+ * 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.util.ArrayList;
+import java.util.List;
+
+import org.springframework.context.ApplicationListener;
+import org.springframework.resilience.retry.MethodRetryEvent;
+
+/**
+ * @author Juergen Hoeller
+ * @since 7.0.3
+ */
+class MethodRetryEventListener implements ApplicationListener The default is {@value #DEFAULT_MAX_RETRIES}.
* The supplied value will override any previously configured value.
+ * Note that {@link RetryTemplate} effectively only supports an integer
+ * range since it stores all exceptions, so it will always exhaust at
+ * {@code Integer#MAX_VALUE} even if a larger value is specified here.
* You should not specify this configuration option if you have
* configured a custom {@link #backOff(BackOff) BackOff} strategy.
* @param maxRetries the maximum number of retry attempts;
diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java b/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java
index ba89a5417b6..ab0b9a54bc3 100644
--- a/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java
+++ b/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java
@@ -146,7 +146,7 @@ public class RetryTemplate implements RetryOperations {
Throwable lastException = initialException;
long timeout = this.retryPolicy.getTimeout().toMillis();
- while (this.retryPolicy.shouldRetry(lastException)) {
+ while (this.retryPolicy.shouldRetry(lastException) && retryState.getRetryCount() < Integer.MAX_VALUE) {
checkIfTimeoutExceeded(timeout, startTime, 0, retryable, retryState);
try {
@@ -161,9 +161,8 @@ public class RetryTemplate implements RetryOperations {
}
catch (InterruptedException interruptedException) {
Thread.currentThread().interrupt();
- RetryException retryException = new RetryException(
- "Interrupted during back-off for retryable operation '%s'".formatted(retryableName),
- retryState);
+ RetryException retryException = new RetryException("Interrupted during back-off for " +
+ "retryable operation '%s'; aborting execution".formatted(retryableName), retryState);
this.retryListener.onRetryPolicyInterruption(this.retryPolicy, retryable, retryException);
throw retryException;
}
diff --git a/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java b/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java
index 775855dcace..000d5f63105 100644
--- a/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java
+++ b/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java
@@ -258,7 +258,7 @@ class RetryTemplateTests {
assertThatExceptionOfType(RetryException.class)
.isThrownBy(() -> retryTemplate.execute(retryable))
- .withMessageMatching("Interrupted during back-off for retryable operation '.+?'")
+ .withMessageMatching("Interrupted during back-off for retryable operation '.+?'; aborting execution")
.withCause(exception)
.satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty())
.satisfies(throwable -> assertThat(throwable.getRetryCount()).isZero())