Introduce MethodRetryEvent for @Retryable execution

Closes gh-35382
This commit is contained in:
Juergen Hoeller
2025-12-23 09:28:23 +01:00
parent fc1ff88ede
commit 92a43c007a
10 changed files with 367 additions and 30 deletions
@@ -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 {
@@ -34,7 +34,10 @@ import org.springframework.resilience.retry.MethodRetryPredicate;
*
* <p>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.
*
* <p>For tracking the exceptions encountered by method-level retry processing,
* consider a {@link org.springframework.resilience.retry.MethodRetryEvent} listener.
*
* <p>Inspired by the <a href="https://github.com/spring-projects/spring-retry">Spring Retry</a>
* project but redesigned as a minimal core retry feature in the Spring Framework.
@@ -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);
@@ -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<MethodRetryEvent>} bean or an
* {@code @EventListener(MethodRetryEvent.class)} method.
*
* @author Juergen Hoeller
* @since 7.0.3
* @see AbstractRetryInterceptor
* @see org.springframework.resilience.annotation.Retryable
* @see org.springframework.context.ApplicationListener
* @see org.springframework.context.event.EventListener
*/
@SuppressWarnings("serial")
public class MethodRetryEvent extends ApplicationEvent {
private final Throwable failure;
private final boolean retryAborted;
/**
* Create a new event for the given retryable method invocation.
* @param invocation the retryable method invocation
* @param failure the exception encountered
* @param retryAborted whether the current failure led to the retry execution getting aborted
*/
public MethodRetryEvent(MethodInvocation invocation, Throwable failure, boolean retryAborted) {
super(invocation);
this.failure = failure;
this.retryAborted = retryAborted;
}
/**
* Return the method invocation that triggered this event.
*/
@Override
public MethodInvocation getSource() {
return (MethodInvocation) super.getSource();
}
/**
* Return the method that triggered this event.
*/
public Method getMethod() {
return getSource().getMethod();
}
/**
* Return the exception encountered.
* <p>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.
* <p>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.
* <p>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() + "]";
}
}