1919import java .io .IOException ;
2020import java .lang .reflect .Method ;
2121import java .nio .file .AccessDeniedException ;
22+ import java .nio .file .FileSystemException ;
2223import java .time .Duration ;
2324import java .util .concurrent .atomic .AtomicInteger ;
2425
2526import org .assertj .core .api .ThrowingConsumer ;
2627import org .junit .jupiter .api .Test ;
28+ import reactor .core .Exceptions ;
2729import reactor .core .publisher .Flux ;
2830import reactor .core .publisher .Mono ;
2931
3840import org .springframework .resilience .retry .SimpleRetryInterceptor ;
3941
4042import static org .assertj .core .api .Assertions .assertThat ;
43+ import static org .assertj .core .api .Assertions .assertThatExceptionOfType ;
4144import static org .assertj .core .api .Assertions .assertThatIllegalStateException ;
4245import static org .assertj .core .api .Assertions .assertThatRuntimeException ;
4346
4447/**
4548 * @author Juergen Hoeller
49+ * @author Sam Brannen
4650 * @since 7.0
4751 */
4852class ReactiveRetryInterceptorTests {
@@ -56,9 +60,12 @@ void withSimpleInterceptor() {
5660 new MethodRetrySpec ((m , t ) -> true , 5 , Duration .ofMillis (10 ))));
5761 NonAnnotatedBean proxy = (NonAnnotatedBean ) pf .getProxy ();
5862
59- assertThatIllegalStateException ().isThrownBy (() -> proxy .retryOperation ().block ())
63+ assertThatIllegalStateException ()
64+ .isThrownBy (() -> proxy .retryOperation ().block ())
6065 .satisfies (isRetryExhaustedException ())
61- .withCauseInstanceOf (IOException .class ).havingCause ().withMessage ("6" );
66+ .havingCause ()
67+ .isInstanceOf (IOException .class )
68+ .withMessage ("6" );
6269 assertThat (target .counter .get ()).isEqualTo (6 );
6370 }
6471
@@ -72,34 +79,94 @@ void withPostProcessorForMethod() {
7279 AnnotatedMethodBean proxy = bf .getBean (AnnotatedMethodBean .class );
7380 AnnotatedMethodBean target = (AnnotatedMethodBean ) AopProxyUtils .getSingletonTarget (proxy );
7481
75- assertThatIllegalStateException ().isThrownBy (() -> proxy .retryOperation ().block ())
82+ assertThatIllegalStateException ()
83+ .isThrownBy (() -> proxy .retryOperation ().block ())
7684 .satisfies (isRetryExhaustedException ())
77- .withCauseInstanceOf (IOException .class ).havingCause ().withMessage ("6" );
85+ .havingCause ()
86+ .isInstanceOf (IOException .class )
87+ .withMessage ("6" );
7888 assertThat (target .counter .get ()).isEqualTo (6 );
7989 }
8090
8191 @ Test
82- void withPostProcessorForClass () {
83- DefaultListableBeanFactory bf = new DefaultListableBeanFactory ();
84- bf .registerBeanDefinition ("bean" , new RootBeanDefinition (AnnotatedClassBean .class ));
85- RetryAnnotationBeanPostProcessor bpp = new RetryAnnotationBeanPostProcessor ();
86- bpp .setBeanFactory (bf );
87- bf .addBeanPostProcessor (bpp );
88- AnnotatedClassBean proxy = bf .getBean (AnnotatedClassBean .class );
92+ void withPostProcessorForClassWithExactIncludesMatch () {
93+ AnnotatedClassBean proxy = getProxiedAnnotatedClassBean ();
8994 AnnotatedClassBean target = (AnnotatedClassBean ) AopProxyUtils .getSingletonTarget (proxy );
9095
91- assertThatRuntimeException ().isThrownBy (() -> proxy .retryOperation ().block ())
96+ // Exact includes match: IOException
97+ assertThatRuntimeException ()
98+ .isThrownBy (() -> proxy .ioOperation ().block ())
99+ // Does NOT throw a RetryExhaustedException, because IOException3Predicate
100+ // returns false once the exception's message is "3".
92101 .satisfies (isReactiveException ())
93- .withCauseInstanceOf (IOException .class ).havingCause ().withMessage ("3" );
102+ .havingCause ()
103+ .isInstanceOf (IOException .class )
104+ .withMessage ("3" );
105+ // 1 initial attempt + 2 retries
94106 assertThat (target .counter .get ()).isEqualTo (3 );
95- assertThatRuntimeException ().isThrownBy (() -> proxy .otherOperation ().block ())
96- .satisfies (isReactiveException ())
97- .withCauseInstanceOf (IOException .class );
107+ }
108+
109+ @ Test
110+ void withPostProcessorForClassWithSubtypeIncludesMatch () {
111+ AnnotatedClassBean proxy = getProxiedAnnotatedClassBean ();
112+ AnnotatedClassBean target = (AnnotatedClassBean ) AopProxyUtils .getSingletonTarget (proxy );
113+
114+ // Subtype includes match: FileSystemException
115+ assertThatRuntimeException ()
116+ .isThrownBy (() -> proxy .fileSystemOperation ().block ())
117+ .satisfies (isRetryExhaustedException ())
118+ .withCauseInstanceOf (FileSystemException .class );
119+ // 1 initial attempt + 3 retries
98120 assertThat (target .counter .get ()).isEqualTo (4 );
99- assertThatIllegalStateException ().isThrownBy (() -> proxy .overrideOperation ().blockFirst ())
121+ }
122+
123+ @ Test
124+ void withPostProcessorForClassWithExcludesMatch () {
125+ AnnotatedClassBean proxy = getProxiedAnnotatedClassBean ();
126+ AnnotatedClassBean target = (AnnotatedClassBean ) AopProxyUtils .getSingletonTarget (proxy );
127+
128+ // Exact excludes match: AccessDeniedException
129+ assertThatRuntimeException ()
130+ .isThrownBy (() -> proxy .accessOperation ().block ())
131+ // Does NOT throw a RetryExhaustedException, because no retry is
132+ // performed for an AccessDeniedException.
133+ .satisfies (isReactiveException ())
134+ .withCauseInstanceOf (AccessDeniedException .class );
135+ // 1 initial attempt + 0 retries
136+ assertThat (target .counter .get ()).isEqualTo (1 );
137+ }
138+
139+ @ Test
140+ void withPostProcessorForClassWithIncludesMismatch () {
141+ AnnotatedClassBean proxy = getProxiedAnnotatedClassBean ();
142+ AnnotatedClassBean target = (AnnotatedClassBean ) AopProxyUtils .getSingletonTarget (proxy );
143+
144+ // No match: ArithmeticException
145+ //
146+ // Does NOT throw a RetryExhaustedException because no retry is performed
147+ // for an ArithmeticException, since it is not an IOException.
148+ // Does NOT throw a ReactiveException because ArithmeticException is a
149+ // RuntimeException, which reactor.core.Exceptions.propagate(Throwable)
150+ // does not wrap.
151+ assertThatExceptionOfType (ArithmeticException .class )
152+ .isThrownBy (() -> proxy .arithmeticOperation ().block ())
153+ .withMessage ("1" );
154+ // 1 initial attempt + 0 retries
155+ assertThat (target .counter .get ()).isEqualTo (1 );
156+ }
157+
158+ @ Test
159+ void withPostProcessorForClassWithMethodLevelOverride () {
160+ AnnotatedClassBean proxy = getProxiedAnnotatedClassBean ();
161+ AnnotatedClassBean target = (AnnotatedClassBean ) AopProxyUtils .getSingletonTarget (proxy );
162+
163+ // Overridden, local @Retryable declaration
164+ assertThatIllegalStateException ()
165+ .isThrownBy (() -> proxy .overrideOperation ().blockFirst ())
100166 .satisfies (isRetryExhaustedException ())
101167 .withCauseInstanceOf (IOException .class );
102- assertThat (target .counter .get ()).isEqualTo (6 );
168+ // 1 initial attempt + 1 retry
169+ assertThat (target .counter .get ()).isEqualTo (2 );
103170 }
104171
105172 @ Test
@@ -113,9 +180,12 @@ void adaptReactiveResultWithMinimalRetrySpec() {
113180 MinimalRetryBean proxy = (MinimalRetryBean ) pf .getProxy ();
114181
115182 // Should execute only 2 times, because maxAttempts=1 means 1 call + 1 retry
116- assertThatIllegalStateException ().isThrownBy (() -> proxy .retryOperation ().block ())
183+ assertThatIllegalStateException ()
184+ .isThrownBy (() -> proxy .retryOperation ().block ())
117185 .satisfies (isRetryExhaustedException ())
118- .withCauseInstanceOf (IOException .class ).havingCause ().withMessage ("2" );
186+ .havingCause ()
187+ .isInstanceOf (IOException .class )
188+ .withMessage ("2" );
119189 assertThat (target .counter .get ()).isEqualTo (2 );
120190 }
121191
@@ -129,9 +199,12 @@ void adaptReactiveResultWithZeroDelayAndJitter() {
129199 new MethodRetrySpec ((m , t ) -> true , 3 , Duration .ZERO , Duration .ofMillis (10 ), 2.0 , Duration .ofMillis (100 ))));
130200 ZeroDelayJitterBean proxy = (ZeroDelayJitterBean ) pf .getProxy ();
131201
132- assertThatIllegalStateException ().isThrownBy (() -> proxy .retryOperation ().block ())
202+ assertThatIllegalStateException ()
203+ .isThrownBy (() -> proxy .retryOperation ().block ())
133204 .satisfies (isRetryExhaustedException ())
134- .withCauseInstanceOf (IOException .class ).havingCause ().withMessage ("4" );
205+ .havingCause ()
206+ .isInstanceOf (IOException .class )
207+ .withMessage ("4" );
135208 assertThat (target .counter .get ()).isEqualTo (4 );
136209 }
137210
@@ -145,9 +218,12 @@ void adaptReactiveResultWithJitterGreaterThanDelay() {
145218 new MethodRetrySpec ((m , t ) -> true , 3 , Duration .ofMillis (5 ), Duration .ofMillis (20 ), 1.5 , Duration .ofMillis (50 ))));
146219 JitterGreaterThanDelayBean proxy = (JitterGreaterThanDelayBean ) pf .getProxy ();
147220
148- assertThatIllegalStateException ().isThrownBy (() -> proxy .retryOperation ().block ())
149- .satisfies (ex -> assertThat (ex .getClass ().getSimpleName ()).isEqualTo ("RetryExhaustedException" ))
150- .withCauseInstanceOf (IOException .class ).havingCause ().withMessage ("4" );
221+ assertThatIllegalStateException ()
222+ .isThrownBy (() -> proxy .retryOperation ().block ())
223+ .satisfies (isRetryExhaustedException ())
224+ .havingCause ()
225+ .isInstanceOf (IOException .class )
226+ .withMessage ("4" );
151227 assertThat (target .counter .get ()).isEqualTo (4 );
152228 }
153229
@@ -161,9 +237,12 @@ void adaptReactiveResultWithFluxMultiValue() {
161237 new MethodRetrySpec ((m , t ) -> true , 3 , Duration .ofMillis (10 ), Duration .ofMillis (5 ), 2.0 , Duration .ofMillis (100 ))));
162238 FluxMultiValueBean proxy = (FluxMultiValueBean ) pf .getProxy ();
163239
164- assertThatIllegalStateException ().isThrownBy (() -> proxy .retryOperation ().blockFirst ())
240+ assertThatIllegalStateException ()
241+ .isThrownBy (() -> proxy .retryOperation ().blockFirst ())
165242 .satisfies (isRetryExhaustedException ())
166- .withCauseInstanceOf (IOException .class ).havingCause ().withMessage ("4" );
243+ .havingCause ()
244+ .isInstanceOf (IOException .class )
245+ .withMessage ("4" );
167246 assertThat (target .counter .get ()).isEqualTo (4 );
168247 }
169248
@@ -184,28 +263,41 @@ void adaptReactiveResultWithSuccessfulOperation() {
184263 }
185264
186265 @ Test
187- void adaptReactiveResultWithImmediateFailure () {
188- // Test immediate failure case
189- ImmediateFailureBean target = new ImmediateFailureBean ();
266+ void adaptReactiveResultWithAlwaysFailingOperation () {
267+ // Test "always fails" case, ensuring retry mechanism stops after maxAttempts (3)
268+ AlwaysFailsBean target = new AlwaysFailsBean ();
190269 ProxyFactory pf = new ProxyFactory ();
191270 pf .setTarget (target );
192271 pf .addAdvice (new SimpleRetryInterceptor (
193272 new MethodRetrySpec ((m , t ) -> true , 3 , Duration .ofMillis (10 ), Duration .ofMillis (5 ), 1.5 , Duration .ofMillis (50 ))));
194- ImmediateFailureBean proxy = (ImmediateFailureBean ) pf .getProxy ();
273+ AlwaysFailsBean proxy = (AlwaysFailsBean ) pf .getProxy ();
195274
196- assertThatIllegalStateException ().isThrownBy (() -> proxy .retryOperation ().block ())
275+ assertThatIllegalStateException ()
276+ .isThrownBy (() -> proxy .retryOperation ().block ())
197277 .satisfies (isRetryExhaustedException ())
198- .withCauseInstanceOf (RuntimeException .class ).havingCause ().withMessage ("immediate failure" );
278+ .havingCause ()
279+ .isInstanceOf (NumberFormatException .class )
280+ .withMessage ("always fails" );
281+ // 1 initial attempt + 3 retries
199282 assertThat (target .counter .get ()).isEqualTo (4 );
200283 }
201284
202285
203286 private static ThrowingConsumer <? super Throwable > isReactiveException () {
204- return ex -> assertThat (ex .getClass ().getSimpleName ()).isEqualTo ("ReactiveException" );
287+ return ex -> assertThat (ex .getClass ().getName ()).isEqualTo ("reactor.core.Exceptions$ ReactiveException" );
205288 }
206289
207290 private static ThrowingConsumer <? super Throwable > isRetryExhaustedException () {
208- return ex -> assertThat (ex .getClass ().getSimpleName ()).isEqualTo ("RetryExhaustedException" );
291+ return ex -> assertThat (ex ).matches (Exceptions ::isRetryExhausted , "is RetryExhaustedException" );
292+ }
293+
294+ private static AnnotatedClassBean getProxiedAnnotatedClassBean () {
295+ DefaultListableBeanFactory bf = new DefaultListableBeanFactory ();
296+ bf .registerBeanDefinition ("bean" , new RootBeanDefinition (AnnotatedClassBean .class ));
297+ RetryAnnotationBeanPostProcessor bpp = new RetryAnnotationBeanPostProcessor ();
298+ bpp .setBeanFactory (bf );
299+ bf .addBeanPostProcessor (bpp );
300+ return bf .getBean (AnnotatedClassBean .class );
209301 }
210302
211303
@@ -238,26 +330,40 @@ public Mono<Object> retryOperation() {
238330
239331 @ Retryable (delay = 10 , jitter = 5 , multiplier = 2.0 , maxDelay = 40 ,
240332 includes = IOException .class , excludes = AccessDeniedException .class ,
241- predicate = CustomPredicate .class )
333+ predicate = IOException3Predicate .class )
242334 static class AnnotatedClassBean {
243335
244336 AtomicInteger counter = new AtomicInteger ();
245337
246- public Mono <Object > retryOperation () {
338+ public Mono <Object > ioOperation () {
247339 return Mono .fromCallable (() -> {
248340 counter .incrementAndGet ();
249341 throw new IOException (counter .toString ());
250342 });
251343 }
252344
253- public Mono <Object > otherOperation () {
345+ public Mono <Object > fileSystemOperation () {
346+ return Mono .fromCallable (() -> {
347+ counter .incrementAndGet ();
348+ throw new FileSystemException (counter .toString ());
349+ });
350+ }
351+
352+ public Mono <Object > accessOperation () {
254353 return Mono .fromCallable (() -> {
255354 counter .incrementAndGet ();
256355 throw new AccessDeniedException (counter .toString ());
257356 });
258357 }
259358
260- @ Retryable (value = IOException .class , maxAttempts = 1 , delay = 10 )
359+ public Mono <Object > arithmeticOperation () {
360+ return Mono .fromCallable (() -> {
361+ counter .incrementAndGet ();
362+ throw new ArithmeticException (counter .toString ());
363+ });
364+ }
365+
366+ @ Retryable (includes = IOException .class , maxAttempts = 1 , delay = 10 )
261367 public Flux <Object > overrideOperation () {
262368 return Flux .from (Mono .fromCallable (() -> {
263369 counter .incrementAndGet ();
@@ -267,11 +373,11 @@ public Flux<Object> overrideOperation() {
267373 }
268374
269375
270- private static class CustomPredicate implements MethodRetryPredicate {
376+ private static class IOException3Predicate implements MethodRetryPredicate {
271377
272378 @ Override
273379 public boolean shouldRetry (Method method , Throwable throwable ) {
274- return !"3" .equals (throwable .getMessage ());
380+ return !( throwable . getClass () == IOException . class && "3" .equals (throwable .getMessage () ));
275381 }
276382 }
277383
@@ -343,14 +449,14 @@ public Mono<String> retryOperation() {
343449 }
344450
345451
346- static class ImmediateFailureBean {
452+ static class AlwaysFailsBean {
347453
348454 AtomicInteger counter = new AtomicInteger ();
349455
350456 public Mono <Object > retryOperation () {
351457 return Mono .fromCallable (() -> {
352458 counter .incrementAndGet ();
353- throw new RuntimeException ( "immediate failure " );
459+ throw new NumberFormatException ( "always fails " );
354460 });
355461 }
356462 }
0 commit comments