Skip to content

Commit 009b880

Browse files
NeatGuyCodingjhoeller
authored andcommitted
Fix potentially loses precision and jitter is not well capped with unit tests
Signed-off-by: NeatGuyCoding <[email protected]>
1 parent eeedeb3 commit 009b880

File tree

2 files changed

+162
-1
lines changed

2 files changed

+162
-1
lines changed

spring-aop/src/main/java/org/springframework/aop/retry/AbstractRetryInterceptor.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,10 @@ public static Object adaptReactiveResult(
136136

137137
Publisher<?> publisher = adapter.toPublisher(result);
138138
Retry retry = Retry.backoff(spec.maxAttempts(), spec.delay())
139-
.jitter((double) spec.jitter().toMillis() / spec.delay().toMillis())
139+
.jitter(
140+
spec.delay().isZero() ? 0.0 :
141+
Math.max(0.0, Math.min(1.0, spec.jitter().toNanos() / (double) spec.delay().toNanos()))
142+
)
140143
.multiplier(spec.multiplier())
141144
.maxBackoff(spec.maxDelay())
142145
.filter(spec.combinedPredicate().forMethod(method));

spring-aop/src/test/java/org/springframework/aop/retry/ReactiveRetryInterceptorTests.java

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,97 @@ void withPostProcessorForClass() {
126126
assertThat(target.counter.get()).isEqualTo(6);
127127
}
128128

129+
@Test
130+
void adaptReactiveResultWithMinimalRetrySpec() {
131+
// Test minimal retry configuration: maxAttempts=1, delay=0, jitter=0, multiplier=1.0, maxDelay=0
132+
MinimalRetryBean target = new MinimalRetryBean();
133+
ProxyFactory pf = new ProxyFactory();
134+
pf.setTarget(target);
135+
pf.addAdvice(new SimpleRetryInterceptor(
136+
new MethodRetrySpec((m, t) -> true, 1, Duration.ZERO, Duration.ZERO, 1.0, Duration.ZERO)));
137+
MinimalRetryBean proxy = (MinimalRetryBean) pf.getProxy();
138+
139+
// Should execute only 2 times, because maxAttempts=1 means 1 call + 1 retry
140+
assertThatIllegalStateException().isThrownBy(() -> proxy.retryOperation().block())
141+
.withCauseInstanceOf(IOException.class).havingCause().withMessage("2");
142+
assertThat(target.counter.get()).isEqualTo(2);
143+
}
144+
145+
@Test
146+
void adaptReactiveResultWithZeroDelayAndJitter() {
147+
// Test case where delay=0 and jitter>0
148+
ZeroDelayJitterBean target = new ZeroDelayJitterBean();
149+
ProxyFactory pf = new ProxyFactory();
150+
pf.setTarget(target);
151+
pf.addAdvice(new SimpleRetryInterceptor(
152+
new MethodRetrySpec((m, t) -> true, 3, Duration.ZERO, Duration.ofMillis(10), 2.0, Duration.ofMillis(100))));
153+
ZeroDelayJitterBean proxy = (ZeroDelayJitterBean) pf.getProxy();
154+
155+
assertThatIllegalStateException().isThrownBy(() -> proxy.retryOperation().block())
156+
.withCauseInstanceOf(IOException.class).havingCause().withMessage("4");
157+
assertThat(target.counter.get()).isEqualTo(4);
158+
}
159+
160+
@Test
161+
void adaptReactiveResultWithJitterGreaterThanDelay() {
162+
// Test case where jitter > delay
163+
JitterGreaterThanDelayBean target = new JitterGreaterThanDelayBean();
164+
ProxyFactory pf = new ProxyFactory();
165+
pf.setTarget(target);
166+
pf.addAdvice(new SimpleRetryInterceptor(
167+
new MethodRetrySpec((m, t) -> true, 3, Duration.ofMillis(5), Duration.ofMillis(20), 1.5, Duration.ofMillis(50))));
168+
JitterGreaterThanDelayBean proxy = (JitterGreaterThanDelayBean) pf.getProxy();
169+
170+
assertThatIllegalStateException().isThrownBy(() -> proxy.retryOperation().block())
171+
.withCauseInstanceOf(IOException.class).havingCause().withMessage("4");
172+
assertThat(target.counter.get()).isEqualTo(4);
173+
}
174+
175+
@Test
176+
void adaptReactiveResultWithFluxMultiValue() {
177+
// Test Flux multi-value stream case
178+
FluxMultiValueBean target = new FluxMultiValueBean();
179+
ProxyFactory pf = new ProxyFactory();
180+
pf.setTarget(target);
181+
pf.addAdvice(new SimpleRetryInterceptor(
182+
new MethodRetrySpec((m, t) -> true, 3, Duration.ofMillis(10), Duration.ofMillis(5), 2.0, Duration.ofMillis(100))));
183+
FluxMultiValueBean proxy = (FluxMultiValueBean) pf.getProxy();
184+
185+
assertThatIllegalStateException().isThrownBy(() -> proxy.retryOperation().blockFirst())
186+
.withCauseInstanceOf(IOException.class).havingCause().withMessage("4");
187+
assertThat(target.counter.get()).isEqualTo(4);
188+
}
189+
190+
@Test
191+
void adaptReactiveResultWithSuccessfulOperation() {
192+
// Test successful return case, ensuring retry mechanism doesn't activate
193+
SuccessfulOperationBean target = new SuccessfulOperationBean();
194+
ProxyFactory pf = new ProxyFactory();
195+
pf.setTarget(target);
196+
pf.addAdvice(new SimpleRetryInterceptor(
197+
new MethodRetrySpec((m, t) -> true, 5, Duration.ofMillis(10), Duration.ofMillis(5), 2.0, Duration.ofMillis(100))));
198+
SuccessfulOperationBean proxy = (SuccessfulOperationBean) pf.getProxy();
199+
200+
String result = proxy.retryOperation().block();
201+
assertThat(result).isEqualTo("success");
202+
// Should execute only once because of successful return
203+
assertThat(target.counter.get()).isEqualTo(1);
204+
}
205+
206+
@Test
207+
void adaptReactiveResultWithImmediateFailure() {
208+
// Test immediate failure case
209+
ImmediateFailureBean target = new ImmediateFailureBean();
210+
ProxyFactory pf = new ProxyFactory();
211+
pf.setTarget(target);
212+
pf.addAdvice(new SimpleRetryInterceptor(
213+
new MethodRetrySpec((m, t) -> true, 3, Duration.ofMillis(10), Duration.ofMillis(5), 1.5, Duration.ofMillis(50))));
214+
ImmediateFailureBean proxy = (ImmediateFailureBean) pf.getProxy();
215+
216+
assertThatIllegalStateException().isThrownBy(() -> proxy.retryOperation().block())
217+
.withCauseInstanceOf(RuntimeException.class).havingCause().withMessage("immediate failure");
218+
assertThat(target.counter.get()).isEqualTo(4);
219+
}
129220

130221
public static class NonAnnotatedBean {
131222

@@ -193,4 +284,71 @@ public boolean shouldRetry(Method method, Throwable throwable) {
193284
}
194285
}
195286

287+
// Bean classes for boundary testing
288+
public static class MinimalRetryBean {
289+
AtomicInteger counter = new AtomicInteger();
290+
291+
public Mono<Object> retryOperation() {
292+
return Mono.fromCallable(() -> {
293+
counter.incrementAndGet();
294+
throw new IOException(counter.toString());
295+
});
296+
}
297+
}
298+
299+
public static class ZeroDelayJitterBean {
300+
AtomicInteger counter = new AtomicInteger();
301+
302+
public Mono<Object> retryOperation() {
303+
return Mono.fromCallable(() -> {
304+
counter.incrementAndGet();
305+
throw new IOException(counter.toString());
306+
});
307+
}
308+
}
309+
310+
public static class JitterGreaterThanDelayBean {
311+
AtomicInteger counter = new AtomicInteger();
312+
313+
public Mono<Object> retryOperation() {
314+
return Mono.fromCallable(() -> {
315+
counter.incrementAndGet();
316+
throw new IOException(counter.toString());
317+
});
318+
}
319+
}
320+
321+
public static class FluxMultiValueBean {
322+
AtomicInteger counter = new AtomicInteger();
323+
324+
public Flux<Object> retryOperation() {
325+
return Flux.from(Mono.fromCallable(() -> {
326+
counter.incrementAndGet();
327+
throw new IOException(counter.toString());
328+
}));
329+
}
330+
}
331+
332+
public static class SuccessfulOperationBean {
333+
AtomicInteger counter = new AtomicInteger();
334+
335+
public Mono<String> retryOperation() {
336+
return Mono.fromCallable(() -> {
337+
counter.incrementAndGet();
338+
return "success";
339+
});
340+
}
341+
}
342+
343+
public static class ImmediateFailureBean {
344+
AtomicInteger counter = new AtomicInteger();
345+
346+
public Mono<Object> retryOperation() {
347+
return Mono.fromCallable(() -> {
348+
counter.incrementAndGet();
349+
throw new RuntimeException("immediate failure");
350+
});
351+
}
352+
}
353+
196354
}

0 commit comments

Comments
 (0)