Browse Source

Introduce MethodRetryEvent for @Retryable execution

Closes gh-35382
pull/36069/head
Juergen Hoeller 1 month ago
parent
commit
92a43c007a
  1. 15
      spring-context/src/main/java/org/springframework/resilience/annotation/RetryAnnotationBeanPostProcessor.java
  2. 5
      spring-context/src/main/java/org/springframework/resilience/annotation/Retryable.java
  3. 65
      spring-context/src/main/java/org/springframework/resilience/retry/AbstractRetryInterceptor.java
  4. 110
      spring-context/src/main/java/org/springframework/resilience/retry/MethodRetryEvent.java
  5. 38
      spring-context/src/test/java/org/springframework/resilience/MethodRetryEventListener.java
  6. 90
      spring-context/src/test/java/org/springframework/resilience/ReactiveRetryInterceptorTests.java
  7. 62
      spring-context/src/test/java/org/springframework/resilience/RetryInterceptorTests.java
  8. 3
      spring-core/src/main/java/org/springframework/core/retry/RetryPolicy.java
  9. 7
      spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java
  10. 2
      spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java

15
spring-context/src/main/java/org/springframework/resilience/annotation/RetryAnnotationBeanPostProcessor.java

@ -30,6 +30,8 @@ import org.springframework.aop.framework.autoproxy.AbstractBeanFactoryAwareAdvis @@ -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; @@ -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 @@ -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 @@ -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 {

5
spring-context/src/main/java/org/springframework/resilience/annotation/Retryable.java

@ -34,7 +34,10 @@ import org.springframework.resilience.retry.MethodRetryPredicate; @@ -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.

65
spring-context/src/main/java/org/springframework/resilience/retry/AbstractRetryInterceptor.java

@ -31,10 +31,14 @@ import reactor.core.publisher.Mono; @@ -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; @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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
spring-context/src/main/java/org/springframework/resilience/retry/MethodRetryEvent.java

@ -0,0 +1,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
spring-context/src/test/java/org/springframework/resilience/MethodRetryEventListener.java

@ -0,0 +1,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);
}
}

90
spring-context/src/test/java/org/springframework/resilience/ReactiveRetryInterceptorTests.java

@ -17,6 +17,7 @@ @@ -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; @@ -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; @@ -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 { @@ -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 { @@ -391,7 +442,6 @@ class ReactiveRetryInterceptorTests {
// 1 initial attempt + 2 retries
assertThat(target.counter).hasValue(3);
}
}
@ -404,21 +454,29 @@ class ReactiveRetryInterceptorTests { @@ -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 { @@ -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 { @@ -560,7 +636,9 @@ class ReactiveRetryInterceptorTests {
}
@EnableResilientMethods
static class EnablingConfig {
}
// Bean classes for boundary testing

62
spring-context/src/test/java/org/springframework/resilience/RetryInterceptorTests.java

@ -18,6 +18,7 @@ package org.springframework.resilience; @@ -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; @@ -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; @@ -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 { @@ -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 { @@ -372,7 +419,6 @@ class RetryInterceptorTests {
// 1 initial attempt + 2 retries
assertThat(target.counter).isEqualTo(3);
}
}
@ -414,6 +460,20 @@ class RetryInterceptorTests { @@ -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++;

3
spring-core/src/main/java/org/springframework/core/retry/RetryPolicy.java

@ -219,6 +219,9 @@ public interface RetryPolicy { @@ -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;

7
spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java

@ -146,7 +146,7 @@ public class RetryTemplate implements RetryOperations { @@ -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 { @@ -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;
}

2
spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java

@ -258,7 +258,7 @@ class RetryTemplateTests { @@ -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())

Loading…
Cancel
Save