mirror of
https://github.com/spring-projects/spring-framework.git
synced 2026-10-03 21:49:03 +00:00
Introduce MethodRetryEvent for @Retryable execution
Closes gh-35382
This commit is contained in:
+11
-4
@@ -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 {
|
||||
|
||||
|
||||
+4
-1
@@ -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.
|
||||
|
||||
+52
-13
@@ -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);
|
||||
|
||||
+110
@@ -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() + "]";
|
||||
}
|
||||
|
||||
}
|
||||
+38
@@ -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<MethodRetryEvent> {
|
||||
|
||||
public final List<MethodRetryEvent> events = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public void onApplicationEvent(MethodRetryEvent event) {
|
||||
this.events.add(event);
|
||||
}
|
||||
|
||||
}
|
||||
+84
-6
@@ -17,6 +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;
|
||||
@@ -33,8 +34,11 @@ import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.aop.framework.AopProxyUtils;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
|
||||
import org.springframework.beans.factory.BeanFactory;
|
||||
import org.springframework.beans.factory.support.RootBeanDefinition;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import org.springframework.context.support.GenericApplicationContext;
|
||||
import org.springframework.resilience.annotation.EnableResilientMethods;
|
||||
import org.springframework.resilience.annotation.RetryAnnotationBeanPostProcessor;
|
||||
import org.springframework.resilience.annotation.Retryable;
|
||||
import org.springframework.resilience.retry.MethodRetrySpec;
|
||||
@@ -43,6 +47,7 @@ import org.springframework.resilience.retry.SimpleRetryInterceptor;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
|
||||
import static org.assertj.core.api.Assertions.assertThatNoException;
|
||||
import static org.assertj.core.api.Assertions.assertThatRuntimeException;
|
||||
|
||||
/**
|
||||
@@ -184,6 +189,52 @@ class ReactiveRetryInterceptorTests {
|
||||
assertThat(target.counter).hasValue(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withMethodRetryEventListener() throws Exception {
|
||||
AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext();
|
||||
ctx.registerBeanDefinition("bean", new RootBeanDefinition(AnnotatedMethodBean.class));
|
||||
ctx.registerBeanDefinition("config", new RootBeanDefinition(EnablingConfig.class));
|
||||
MethodRetryEventListener listener = new MethodRetryEventListener();
|
||||
ctx.addApplicationListener(listener);
|
||||
ctx.refresh();
|
||||
AnnotatedMethodBean proxy = ctx.getBean(AnnotatedMethodBean.class);
|
||||
AnnotatedMethodBean target = (AnnotatedMethodBean) AopProxyUtils.getSingletonTarget(proxy);
|
||||
|
||||
Method method1 = AnnotatedMethodBean.class.getMethod("retryOperation");
|
||||
assertThatIllegalStateException()
|
||||
.isThrownBy(() -> proxy.retryOperation().block())
|
||||
.satisfies(isRetryExhaustedException());
|
||||
assertThat(target.counter).hasValue(6);
|
||||
assertThat(listener.events).hasSize(7);
|
||||
for (int i = 0; i < 6; i++) {
|
||||
String msg = Integer.toString(i + 1);
|
||||
assertThat(listener.events.get(i))
|
||||
.satisfies(event -> assertThat(event.getMethod()).isEqualTo(method1))
|
||||
.satisfies(event -> assertThat(event.getFailure()).hasMessage(msg).isInstanceOf(IOException.class))
|
||||
.satisfies(event -> assertThat(event.isRetryAborted()).isFalse());
|
||||
}
|
||||
assertThat(listener.events.get(6))
|
||||
.satisfies(event -> assertThat(event.getMethod()).isEqualTo(method1))
|
||||
.satisfies(event -> assertThat(event.getFailure()).satisfies(isRetryExhaustedException()))
|
||||
.satisfies(event -> assertThat(event.isRetryAborted()).isTrue());
|
||||
|
||||
listener.events.clear();
|
||||
target.counter.set(0);
|
||||
assertThatNoException().isThrownBy(() -> proxy.retryOperationWithInitialSuccess().block());
|
||||
assertThat(target.counter).hasValue(1);
|
||||
assertThat(listener.events).isEmpty();
|
||||
|
||||
target.counter.set(0);
|
||||
Method method2 = AnnotatedMethodBean.class.getMethod("retryOperationWithSuccessAfterInitialFailure");
|
||||
assertThatNoException().isThrownBy(() -> proxy.retryOperationWithSuccessAfterInitialFailure().block());
|
||||
assertThat(target.counter).hasValue(2);
|
||||
assertThat(listener.events).hasSize(1);
|
||||
assertThat(listener.events.get(0))
|
||||
.satisfies(event -> assertThat(event.getMethod()).isEqualTo(method2))
|
||||
.satisfies(event -> assertThat(event.getFailure()).hasMessage("1").isInstanceOf(IOException.class))
|
||||
.satisfies(event -> assertThat(event.isRetryAborted()).isFalse());
|
||||
}
|
||||
|
||||
@Test
|
||||
void adaptReactiveResultWithMinimalRetrySpec() {
|
||||
// Test minimal retry configuration: maxRetries=1, delay=0, jitter=0, multiplier=1.0, maxDelay=0
|
||||
@@ -391,7 +442,6 @@ class ReactiveRetryInterceptorTests {
|
||||
// 1 initial attempt + 2 retries
|
||||
assertThat(target.counter).hasValue(3);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -404,21 +454,29 @@ class ReactiveRetryInterceptorTests {
|
||||
}
|
||||
|
||||
private static AnnotatedMethodBean getProxiedAnnotatedMethodBean() {
|
||||
DefaultListableBeanFactory bf = createBeanFactoryFor(AnnotatedMethodBean.class);
|
||||
BeanFactory bf = createBeanFactoryFor(AnnotatedMethodBean.class);
|
||||
return bf.getBean(AnnotatedMethodBean.class);
|
||||
}
|
||||
|
||||
private static AnnotatedClassBean getProxiedAnnotatedClassBean() {
|
||||
DefaultListableBeanFactory bf = createBeanFactoryFor(AnnotatedClassBean.class);
|
||||
BeanFactory bf = createBeanFactoryFor(AnnotatedClassBean.class);
|
||||
return bf.getBean(AnnotatedClassBean.class);
|
||||
}
|
||||
|
||||
private static DefaultListableBeanFactory createBeanFactoryFor(Class<?> beanClass) {
|
||||
private static BeanFactory createBeanFactoryFor(Class<?> beanClass) {
|
||||
/*
|
||||
DefaultListableBeanFactory bf = new DefaultListableBeanFactory();
|
||||
bf.registerBeanDefinition("bean", new RootBeanDefinition(beanClass));
|
||||
RetryAnnotationBeanPostProcessor bpp = new RetryAnnotationBeanPostProcessor();
|
||||
bpp.setBeanFactory(bf);
|
||||
bf.addBeanPostProcessor(bpp);
|
||||
*/
|
||||
GenericApplicationContext bf = new GenericApplicationContext();
|
||||
bf.registerBeanDefinition("bean", new RootBeanDefinition(beanClass));
|
||||
bf.registerBeanDefinition("processor", new RootBeanDefinition(RetryAnnotationBeanPostProcessor.class));
|
||||
bf.registerBeanDefinition("listener", new RootBeanDefinition(MethodRetryEventListener.class));
|
||||
bf.refresh();
|
||||
|
||||
return bf;
|
||||
}
|
||||
|
||||
@@ -448,6 +506,24 @@ class ReactiveRetryInterceptorTests {
|
||||
});
|
||||
}
|
||||
|
||||
@Retryable(maxRetries = 5, delay = 10)
|
||||
public Mono<String> retryOperationWithInitialSuccess() {
|
||||
return Mono.fromCallable(() -> {
|
||||
counter.incrementAndGet();
|
||||
return "success";
|
||||
});
|
||||
}
|
||||
|
||||
@Retryable(maxRetries = 5, delay = 10)
|
||||
public Mono<String> retryOperationWithSuccessAfterInitialFailure() {
|
||||
return Mono.fromCallable(() -> {
|
||||
if (counter.incrementAndGet() == 1) {
|
||||
throw new IOException(counter.toString());
|
||||
}
|
||||
return "success";
|
||||
});
|
||||
}
|
||||
|
||||
@Retryable(timeout = 555, delay = 10)
|
||||
public Mono<String> retryOperationWithTimeoutNotExceededAfterInitialSuccess() {
|
||||
return Mono.fromCallable(() -> {
|
||||
@@ -560,7 +636,9 @@ class ReactiveRetryInterceptorTests {
|
||||
}
|
||||
|
||||
|
||||
|
||||
@EnableResilientMethods
|
||||
static class EnablingConfig {
|
||||
}
|
||||
|
||||
|
||||
// Bean classes for boundary testing
|
||||
|
||||
+61
-1
@@ -18,6 +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;
|
||||
@@ -41,6 +42,7 @@ import org.springframework.beans.factory.support.RootBeanDefinition;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import org.springframework.context.support.GenericApplicationContext;
|
||||
import org.springframework.core.env.PropertiesPropertySource;
|
||||
import org.springframework.core.retry.RetryException;
|
||||
import org.springframework.resilience.annotation.ConcurrencyLimit;
|
||||
import org.springframework.resilience.annotation.EnableResilientMethods;
|
||||
import org.springframework.resilience.annotation.RetryAnnotationBeanPostProcessor;
|
||||
@@ -53,6 +55,7 @@ import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
import static org.assertj.core.api.Assertions.assertThatIOException;
|
||||
import static org.assertj.core.api.Assertions.assertThatNoException;
|
||||
import static org.assertj.core.api.Assertions.assertThatRuntimeException;
|
||||
|
||||
/**
|
||||
@@ -312,6 +315,50 @@ class RetryInterceptorTests {
|
||||
assertThat(target.counter).hasValue(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withMethodRetryEventListener() throws Exception {
|
||||
AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext();
|
||||
ctx.registerBeanDefinition("bean", new RootBeanDefinition(AnnotatedMethodBean.class));
|
||||
ctx.registerBeanDefinition("config", new RootBeanDefinition(EnablingConfig.class));
|
||||
MethodRetryEventListener listener = new MethodRetryEventListener();
|
||||
ctx.addApplicationListener(listener);
|
||||
ctx.refresh();
|
||||
AnnotatedMethodBean proxy = ctx.getBean(AnnotatedMethodBean.class);
|
||||
AnnotatedMethodBean target = (AnnotatedMethodBean) AopProxyUtils.getSingletonTarget(proxy);
|
||||
|
||||
Method method1 = AnnotatedMethodBean.class.getMethod("retryOperation");
|
||||
assertThatIOException().isThrownBy(proxy::retryOperation).withMessage("6");
|
||||
assertThat(target.counter).isEqualTo(6);
|
||||
assertThat(listener.events).hasSize(7);
|
||||
for (int i = 0; i < 6; i++) {
|
||||
String msg = Integer.toString(i + 1);
|
||||
assertThat(listener.events.get(i))
|
||||
.satisfies(event -> assertThat(event.getMethod()).isEqualTo(method1))
|
||||
.satisfies(event -> assertThat(event.getFailure()).hasMessage(msg).isInstanceOf(IOException.class))
|
||||
.satisfies(event -> assertThat(event.isRetryAborted()).isFalse());
|
||||
}
|
||||
assertThat(listener.events.get(6))
|
||||
.satisfies(event -> assertThat(event.getMethod()).isEqualTo(method1))
|
||||
.satisfies(event -> assertThat(event.getFailure()).isInstanceOf(RetryException.class))
|
||||
.satisfies(event -> assertThat(event.isRetryAborted()).isTrue());
|
||||
|
||||
listener.events.clear();
|
||||
target.counter = 0;
|
||||
assertThatNoException().isThrownBy(proxy::retryOperationWithInitialSuccess);
|
||||
assertThat(target.counter).isEqualTo(1);
|
||||
assertThat(listener.events).isEmpty();
|
||||
|
||||
target.counter = 0;
|
||||
Method method2 = AnnotatedMethodBean.class.getMethod("retryOperationWithSuccessAfterInitialFailure");
|
||||
assertThatNoException().isThrownBy(proxy::retryOperationWithSuccessAfterInitialFailure);
|
||||
assertThat(target.counter).isEqualTo(2);
|
||||
assertThat(listener.events).hasSize(1);
|
||||
assertThat(listener.events.get(0))
|
||||
.satisfies(event -> assertThat(event.getMethod()).isEqualTo(method2))
|
||||
.satisfies(event -> assertThat(event.getFailure()).hasMessage("1").isInstanceOf(IOException.class))
|
||||
.satisfies(event -> assertThat(event.isRetryAborted()).isFalse());
|
||||
}
|
||||
|
||||
|
||||
@Nested
|
||||
class TimeoutTests {
|
||||
@@ -372,7 +419,6 @@ class RetryInterceptorTests {
|
||||
// 1 initial attempt + 2 retries
|
||||
assertThat(target.counter).isEqualTo(3);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -414,6 +460,20 @@ class RetryInterceptorTests {
|
||||
throw new IOException(Integer.toString(counter));
|
||||
}
|
||||
|
||||
@Retryable(maxRetries = 5, delay = 10)
|
||||
public String retryOperationWithInitialSuccess() {
|
||||
counter++;
|
||||
return "success";
|
||||
}
|
||||
|
||||
@Retryable(maxRetries = 5, delay = 10)
|
||||
public String retryOperationWithSuccessAfterInitialFailure() throws IOException{
|
||||
if (++counter == 1) {
|
||||
throw new IOException(Integer.toString(counter));
|
||||
}
|
||||
return "success";
|
||||
}
|
||||
|
||||
@Retryable(timeout = 555, delay = 10)
|
||||
public String retryOperationWithTimeoutNotExceededAfterInitialSuccess() {
|
||||
counter++;
|
||||
|
||||
@@ -219,6 +219,9 @@ public interface RetryPolicy {
|
||||
* invoked at least once and at most 5 times.
|
||||
* <p>The default is {@value #DEFAULT_MAX_RETRIES}.
|
||||
* <p>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.
|
||||
* <p>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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user