Skip to content

Commit 92a43c0

Browse files
committed
Introduce MethodRetryEvent for @retryable execution
Closes gh-35382
1 parent fc1ff88 commit 92a43c0

File tree

10 files changed

+367
-30
lines changed

10 files changed

+367
-30
lines changed

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

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
import org.springframework.aop.support.ComposablePointcut;
3131
import org.springframework.aop.support.DefaultPointcutAdvisor;
3232
import org.springframework.aop.support.annotation.AnnotationMatchingPointcut;
33+
import org.springframework.context.ApplicationEventPublisher;
34+
import org.springframework.context.ApplicationEventPublisherAware;
3335
import org.springframework.context.EmbeddedValueResolverAware;
3436
import org.springframework.core.MethodClassKey;
3537
import org.springframework.core.annotation.AnnotatedElementUtils;
@@ -52,7 +54,9 @@
5254
*/
5355
@SuppressWarnings("serial")
5456
public class RetryAnnotationBeanPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor
55-
implements EmbeddedValueResolverAware {
57+
implements ApplicationEventPublisherAware, EmbeddedValueResolverAware {
58+
59+
private final RetryAnnotationInterceptor interceptor = new RetryAnnotationInterceptor();
5660

5761
private @Nullable StringValueResolver embeddedValueResolver;
5862

@@ -62,9 +66,7 @@ public RetryAnnotationBeanPostProcessor() {
6266

6367
Pointcut cpc = new AnnotationMatchingPointcut(Retryable.class, true);
6468
Pointcut mpc = new AnnotationMatchingPointcut(null, Retryable.class, true);
65-
this.advisor = new DefaultPointcutAdvisor(
66-
new ComposablePointcut(cpc).union(mpc),
67-
new RetryAnnotationInterceptor());
69+
this.advisor = new DefaultPointcutAdvisor(new ComposablePointcut(cpc).union(mpc), this.interceptor);
6870
}
6971

7072

@@ -73,6 +75,11 @@ public void setEmbeddedValueResolver(StringValueResolver resolver) {
7375
this.embeddedValueResolver = resolver;
7476
}
7577

78+
@Override
79+
public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
80+
this.interceptor.setApplicationEventPublisher(applicationEventPublisher);
81+
}
82+
7683

7784
private class RetryAnnotationInterceptor extends AbstractRetryInterceptor {
7885

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@
3434
*
3535
* <p>Aligned with {@link org.springframework.core.retry.RetryTemplate}
3636
* as well as Reactor's retry support, either re-invoking an imperative
37-
* target method or decorating a reactive result accordingly.
37+
* target method or decorating a returned reactive publisher accordingly.
38+
*
39+
* <p>For tracking the exceptions encountered by method-level retry processing,
40+
* consider a {@link org.springframework.resilience.retry.MethodRetryEvent} listener.
3841
*
3942
* <p>Inspired by the <a href="https://github.com/spring-projects/spring-retry">Spring Retry</a>
4043
* project but redesigned as a minimal core retry feature in the Spring Framework.

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

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,14 @@
3131
import reactor.util.retry.Retry;
3232

3333
import org.springframework.aop.ProxyMethodInvocation;
34+
import org.springframework.context.ApplicationEventPublisher;
35+
import org.springframework.context.ApplicationEventPublisherAware;
3436
import org.springframework.core.ReactiveAdapter;
3537
import org.springframework.core.ReactiveAdapterRegistry;
3638
import org.springframework.core.retry.RetryException;
39+
import org.springframework.core.retry.RetryListener;
3740
import org.springframework.core.retry.RetryPolicy;
41+
import org.springframework.core.retry.RetryState;
3842
import org.springframework.core.retry.RetryTemplate;
3943
import org.springframework.core.retry.Retryable;
4044
import org.springframework.util.ClassUtils;
@@ -50,7 +54,7 @@
5054
* @see Mono#retryWhen
5155
* @see Flux#retryWhen
5256
*/
53-
public abstract class AbstractRetryInterceptor implements MethodInterceptor {
57+
public abstract class AbstractRetryInterceptor implements MethodInterceptor, ApplicationEventPublisherAware {
5458

5559
private static final Log logger = LogFactory.getLog(AbstractRetryInterceptor.class);
5660

@@ -62,6 +66,8 @@ public abstract class AbstractRetryInterceptor implements MethodInterceptor {
6266

6367
private final @Nullable ReactiveAdapterRegistry reactiveAdapterRegistry;
6468

69+
private @Nullable ApplicationEventPublisher applicationEventPublisher;
70+
6571

6672
public AbstractRetryInterceptor() {
6773
if (REACTIVE_STREAMS_PRESENT) {
@@ -72,6 +78,11 @@ public AbstractRetryInterceptor() {
7278
}
7379
}
7480

81+
@Override
82+
public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
83+
this.applicationEventPublisher = applicationEventPublisher;
84+
}
85+
7586

7687
@Override
7788
public @Nullable Object invoke(MethodInvocation invocation) throws Throwable {
@@ -90,7 +101,7 @@ public AbstractRetryInterceptor() {
90101
if (result == null) {
91102
return null;
92103
}
93-
return ReactorDelegate.adaptReactiveResult(result, adapter, spec, method);
104+
return new ReactorDelegate().adaptReactiveResult(invocation, result, adapter, spec);
94105
}
95106
}
96107

@@ -105,7 +116,17 @@ public AbstractRetryInterceptor() {
105116
.multiplier(spec.multiplier())
106117
.maxDelay(spec.maxDelay())
107118
.build();
119+
108120
RetryTemplate retryTemplate = new RetryTemplate(retryPolicy);
121+
retryTemplate.setRetryListener(new RetryListener() {
122+
@Override
123+
public void onRetryableExecution(RetryPolicy retryPolicy, Retryable<?> retryable, RetryState retryState) {
124+
if (!retryState.isSuccessful()) {
125+
onEvent(new MethodRetryEvent(invocation, retryState.getLastException(), false));
126+
}
127+
}
128+
});
129+
109130
String methodName = ClassUtils.getQualifiedMethodName(method, (target != null ? target.getClass() : null));
110131

111132
try {
@@ -122,13 +143,21 @@ public String getName() {
122143
});
123144
}
124145
catch (RetryException ex) {
146+
onEvent(new MethodRetryEvent(invocation, ex, true));
125147
if (logger.isDebugEnabled()) {
126-
logger.debug("@Retryable operation '%s' failed".formatted(methodName), ex);
148+
logger.debug("Retryable operation '%s' failed".formatted(methodName), ex);
127149
}
128150
throw ex.getCause();
129151
}
130152
}
131153

154+
private void onEvent(MethodRetryEvent event) {
155+
logger.trace(event, event.getFailure());
156+
if (this.applicationEventPublisher != null) {
157+
this.applicationEventPublisher.publishEvent(event);
158+
}
159+
}
160+
132161
/**
133162
* Determine the retry specification for the given method on the given target.
134163
* @param method the currently executing method
@@ -141,29 +170,39 @@ public String getName() {
141170
/**
142171
* Inner class to avoid a hard dependency on Reactive Streams and Reactor at runtime.
143172
*/
144-
private static class ReactorDelegate {
173+
private class ReactorDelegate {
145174

146-
public static Object adaptReactiveResult(
147-
Object result, ReactiveAdapter adapter, MethodRetrySpec spec, Method method) {
175+
public Object adaptReactiveResult(
176+
MethodInvocation invocation, Object result, ReactiveAdapter adapter, MethodRetrySpec spec) {
148177

149178
Publisher<?> publisher = adapter.toPublisher(result);
150179
Retry retry = Retry.backoff(spec.maxRetries(), spec.delay())
151180
.jitter(calculateJitterFactor(spec))
152181
.multiplier(spec.multiplier())
153182
.maxBackoff(spec.maxDelay())
154-
.filter(spec.combinedPredicate().forMethod(method));
183+
.filter(spec.combinedPredicate().forMethod(invocation.getMethod()));
155184

156185
Duration timeout = spec.timeout();
157186
boolean timeoutIsPositive = (!timeout.isNegative() && !timeout.isZero());
158187
if (adapter.isMultiValue()) {
159-
publisher = (timeoutIsPositive ?
160-
Flux.from(publisher).retryWhen(retry).timeout(timeout) :
161-
Flux.from(publisher).retryWhen(retry));
188+
Flux<?> flux = Flux.from(publisher)
189+
.doOnError(ex -> onEvent(new MethodRetryEvent(invocation, ex, false)))
190+
.retryWhen(retry);
191+
if (timeoutIsPositive) {
192+
flux = flux.timeout(timeout);
193+
}
194+
flux = flux.doOnError(ex -> onEvent(new MethodRetryEvent(invocation, ex, true)));
195+
publisher = flux;
162196
}
163197
else {
164-
publisher = (timeoutIsPositive ?
165-
Mono.from(publisher).retryWhen(retry).timeout(timeout) :
166-
Mono.from(publisher).retryWhen(retry));
198+
Mono<?> mono = Mono.from(publisher)
199+
.doOnError(ex -> onEvent(new MethodRetryEvent(invocation, ex, false)))
200+
.retryWhen(retry);
201+
if (timeoutIsPositive) {
202+
mono = mono.timeout(timeout);
203+
}
204+
mono = mono.doOnError(ex -> onEvent(new MethodRetryEvent(invocation, ex, true)));
205+
publisher = mono;
167206
}
168207

169208
return adapter.fromPublisher(publisher);
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
* Copyright 2002-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.resilience.retry;
18+
19+
import java.lang.reflect.Method;
20+
21+
import org.aopalliance.intercept.MethodInvocation;
22+
23+
import org.springframework.context.ApplicationEvent;
24+
import org.springframework.util.ClassUtils;
25+
26+
/**
27+
* Event published for every exception encountered during retryable method invocation.
28+
* Can be listened to via an {@code ApplicationListener<MethodRetryEvent>} bean or an
29+
* {@code @EventListener(MethodRetryEvent.class)} method.
30+
*
31+
* @author Juergen Hoeller
32+
* @since 7.0.3
33+
* @see AbstractRetryInterceptor
34+
* @see org.springframework.resilience.annotation.Retryable
35+
* @see org.springframework.context.ApplicationListener
36+
* @see org.springframework.context.event.EventListener
37+
*/
38+
@SuppressWarnings("serial")
39+
public class MethodRetryEvent extends ApplicationEvent {
40+
41+
private final Throwable failure;
42+
43+
private final boolean retryAborted;
44+
45+
46+
/**
47+
* Create a new event for the given retryable method invocation.
48+
* @param invocation the retryable method invocation
49+
* @param failure the exception encountered
50+
* @param retryAborted whether the current failure led to the retry execution getting aborted
51+
*/
52+
public MethodRetryEvent(MethodInvocation invocation, Throwable failure, boolean retryAborted) {
53+
super(invocation);
54+
this.failure = failure;
55+
this.retryAborted = retryAborted;
56+
}
57+
58+
59+
/**
60+
* Return the method invocation that triggered this event.
61+
*/
62+
@Override
63+
public MethodInvocation getSource() {
64+
return (MethodInvocation) super.getSource();
65+
}
66+
67+
/**
68+
* Return the method that triggered this event.
69+
*/
70+
public Method getMethod() {
71+
return getSource().getMethod();
72+
}
73+
74+
/**
75+
* Return the exception encountered.
76+
* <p>This may be an exception thrown by the method or emitted by the reactive
77+
* publisher returned from the method, or a terminal exception on retry
78+
* exhaustion, interruption or timeout.
79+
* <p>For {@link org.springframework.core.retry.RetryTemplate} executions,
80+
* an {@code instanceof RetryException} check identifies a final exception.
81+
* For Reactor pipelines, {@code Exceptions.isRetryExhausted} identifies an
82+
* exhaustion exception, whereas {@code instanceof TimeoutException} reveals
83+
* a timeout scenario.
84+
* @see #isRetryAborted()
85+
* @see org.springframework.core.retry.RetryException
86+
* @see reactor.core.Exceptions#isRetryExhausted
87+
* @see java.util.concurrent.TimeoutException
88+
*/
89+
public Throwable getFailure() {
90+
return this.failure;
91+
}
92+
93+
/**
94+
* Return whether the current failure led to the retry execution getting aborted,
95+
* typically indicating exhaustion, interruption or a timeout scenario.
96+
* <p>If this returns {@code true}, {@link #getFailure()} exposes the final exception
97+
* thrown by the retry infrastructure (rather than thrown by the method itself).
98+
* @see #getFailure()
99+
*/
100+
public boolean isRetryAborted() {
101+
return this.retryAborted;
102+
}
103+
104+
105+
@Override
106+
public String toString() {
107+
return "MethodRetryEvent: " + ClassUtils.getQualifiedMethodName(getMethod()) + " [" + getFailure() + "]";
108+
}
109+
110+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* Copyright 2002-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.resilience;
18+
19+
import java.util.ArrayList;
20+
import java.util.List;
21+
22+
import org.springframework.context.ApplicationListener;
23+
import org.springframework.resilience.retry.MethodRetryEvent;
24+
25+
/**
26+
* @author Juergen Hoeller
27+
* @since 7.0.3
28+
*/
29+
class MethodRetryEventListener implements ApplicationListener<MethodRetryEvent> {
30+
31+
public final List<MethodRetryEvent> events = new ArrayList<>();
32+
33+
@Override
34+
public void onApplicationEvent(MethodRetryEvent event) {
35+
this.events.add(event);
36+
}
37+
38+
}

0 commit comments

Comments
 (0)