From 924849f55b7641ebd8eaacdb3d8c9f18e919d020 Mon Sep 17 00:00:00 2001 From: junhyeong9812 Date: Tue, 16 Jun 2026 09:43:39 +0900 Subject: [PATCH 1/3] Avoid divide-by-zero in ExponentialBackOff jitter When an ExponentialBackOff is configured with an initialInterval of 0 and a positive jitter, the first nextBackOff() evaluated (jitter * (interval / initialInterval)) performs integer division by zero and throws an ArithmeticException. Both initialInterval = 0 and jitter > 0 are individually accepted configurations -- with jitter = 0, an initialInterval of 0 already yields a delay of 0 -- so the combination should not throw. This commit addresses that by guarding the division so that no jitter scaling is applied when initialInterval is 0, leaving the behavior for positive intervals unchanged. Closes gh-36932 Signed-off-by: junhyeong9812 --- .../util/backoff/ExponentialBackOff.java | 2 +- .../util/ExponentialBackOffTests.java | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/spring-core/src/main/java/org/springframework/util/backoff/ExponentialBackOff.java b/spring-core/src/main/java/org/springframework/util/backoff/ExponentialBackOff.java index fd48e023e15..2747703bd6e 100644 --- a/spring-core/src/main/java/org/springframework/util/backoff/ExponentialBackOff.java +++ b/spring-core/src/main/java/org/springframework/util/backoff/ExponentialBackOff.java @@ -311,7 +311,7 @@ public class ExponentialBackOff implements BackOff { long jitter = getJitter(); if (jitter > 0) { long initialInterval = getInitialInterval(); - long applicableJitter = jitter * (interval / initialInterval); + long applicableJitter = jitter * (initialInterval > 0 ? (interval / initialInterval) : 1); long min = Math.max(interval - applicableJitter, initialInterval); long max = Math.min(interval + applicableJitter, getMaxInterval()); return min + (long) (Math.random() * (max - min)); diff --git a/spring-core/src/test/java/org/springframework/util/ExponentialBackOffTests.java b/spring-core/src/test/java/org/springframework/util/ExponentialBackOffTests.java index 40271f5c744..095ff93d9ed 100644 --- a/spring-core/src/test/java/org/springframework/util/ExponentialBackOffTests.java +++ b/spring-core/src/test/java/org/springframework/util/ExponentialBackOffTests.java @@ -27,6 +27,7 @@ import org.springframework.util.backoff.ExponentialBackOff; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatNoException; /** * Tests for {@link ExponentialBackOff}. @@ -118,6 +119,20 @@ class ExponentialBackOffTests { assertThatIllegalArgumentException().isThrownBy(() -> backOff.setMultiplier(0.9)); } + @Test + void jitterWithZeroInitialInterval() { + // 'initialInterval = 0' and 'jitter > 0' are both individually accepted + // configurations, so their combination must not throw. With initialInterval + // of 0, the first nextBackOff() previously evaluated 'jitter * (0 / 0)', + // resulting in an integer division by zero. + ExponentialBackOff backOff = new ExponentialBackOff(); + backOff.setInitialInterval(0); + backOff.setJitter(100); + + BackOffExecution execution = backOff.start(); + assertThatNoException().isThrownBy(execution::nextBackOff); + } + @Test void maxIntervalReachedImmediately() { ExponentialBackOff backOff = new ExponentialBackOff(1000L, 2.0); From 0d706f8da60c82f0ae33ef66607a2876b7a461f7 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:38:31 +0200 Subject: [PATCH 2/3] Polish contribution See gh-36932 --- .../util/backoff/ExponentialBackOff.java | 2 ++ .../springframework/util/ExponentialBackOffTests.java | 10 ++++------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/util/backoff/ExponentialBackOff.java b/spring-core/src/main/java/org/springframework/util/backoff/ExponentialBackOff.java index 2747703bd6e..fc8b1e1022f 100644 --- a/spring-core/src/main/java/org/springframework/util/backoff/ExponentialBackOff.java +++ b/spring-core/src/main/java/org/springframework/util/backoff/ExponentialBackOff.java @@ -311,6 +311,8 @@ public class ExponentialBackOff implements BackOff { long jitter = getJitter(); if (jitter > 0) { long initialInterval = getInitialInterval(); + // When initialInterval is 0 the interval never grows, so the scale factor + // stays at its baseline value of 1 and the full configured jitter is applied. long applicableJitter = jitter * (initialInterval > 0 ? (interval / initialInterval) : 1); long min = Math.max(interval - applicableJitter, initialInterval); long max = Math.min(interval + applicableJitter, getMaxInterval()); diff --git a/spring-core/src/test/java/org/springframework/util/ExponentialBackOffTests.java b/spring-core/src/test/java/org/springframework/util/ExponentialBackOffTests.java index 095ff93d9ed..50999afc589 100644 --- a/spring-core/src/test/java/org/springframework/util/ExponentialBackOffTests.java +++ b/spring-core/src/test/java/org/springframework/util/ExponentialBackOffTests.java @@ -119,17 +119,15 @@ class ExponentialBackOffTests { assertThatIllegalArgumentException().isThrownBy(() -> backOff.setMultiplier(0.9)); } - @Test + @Test // gh-36932 void jitterWithZeroInitialInterval() { - // 'initialInterval = 0' and 'jitter > 0' are both individually accepted - // configurations, so their combination must not throw. With initialInterval - // of 0, the first nextBackOff() previously evaluated 'jitter * (0 / 0)', - // resulting in an integer division by zero. ExponentialBackOff backOff = new ExponentialBackOff(); backOff.setInitialInterval(0); backOff.setJitter(100); - BackOffExecution execution = backOff.start(); + + // 'initialInterval = 0' and 'jitter > 0' are both individually accepted + // configurations, so their combination must not throw. assertThatNoException().isThrownBy(execution::nextBackOff); } From 846a6a8f7ccd6d2cb3ff38876eb13dd210fdef0d Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:41:03 +0200 Subject: [PATCH 3/3] Document behavior for 0 delay combined with jitter Closes gh-36946 --- .../modules/ROOT/pages/core/resilience.adoc | 14 ++++++++++++++ .../resilience/annotation/Retryable.java | 4 ++++ .../springframework/core/retry/RetryPolicy.java | 5 +++++ .../util/backoff/ExponentialBackOff.java | 4 ++++ 4 files changed, 27 insertions(+) diff --git a/framework-docs/modules/ROOT/pages/core/resilience.adoc b/framework-docs/modules/ROOT/pages/core/resilience.adoc index a186c9c81ff..fa68ac4fd43 100644 --- a/framework-docs/modules/ROOT/pages/core/resilience.adoc +++ b/framework-docs/modules/ROOT/pages/core/resilience.adoc @@ -81,6 +81,13 @@ public void sendNotification() { } ---- +[NOTE] +==== +When `delay` is `0` combined with a positive `jitter`, the delay never grows +regardless of any configured `multiplier`, so the full configured `jitter` is +applied directly as a random delay in the range from `0` to `min(jitter, maxDelay)`. +==== + Last but not least, `@Retryable` also works for reactive methods with a reactive return type, decorating the pipeline with Reactor's retry capabilities: @@ -263,6 +270,13 @@ and an exponential back-off strategy with a bit of jitter. () -> jmsClient.destination("notifications").send(...)); ---- +[NOTE] +==== +When `delay` is zero combined with a positive `jitter`, the delay never grows +regardless of any configured `multiplier`, so the full configured `jitter` is +applied directly as a random delay in the range from zero to `min(jitter, maxDelay)`. +==== + [TIP] ==== Although the factory methods and builder API for `RetryPolicy` cover most common diff --git a/spring-context/src/main/java/org/springframework/resilience/annotation/Retryable.java b/spring-context/src/main/java/org/springframework/resilience/annotation/Retryable.java index f86d69d6cc4..9f929febcdb 100644 --- a/spring-context/src/main/java/org/springframework/resilience/annotation/Retryable.java +++ b/spring-context/src/main/java/org/springframework/resilience/annotation/Retryable.java @@ -197,6 +197,10 @@ public @interface Retryable { * and {@code delay + jitter} but never below the base {@link #delay()} or * above {@link #maxDelay()}. If a multiplier is specified, it is applied * to the jitter value as well. + *

When {@link #delay()} is {@code 0} combined with a positive jitter, + * the delay never grows regardless of any configured multiplier, so the + * full configured jitter is applied directly as a random delay in the range + * from {@code 0} to {@code min(jitter, maxDelay)}. *

The time unit is milliseconds by default but can be overridden via * {@link #timeUnit}. *

The default is 0 (no jitter). diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryPolicy.java b/spring-core/src/main/java/org/springframework/core/retry/RetryPolicy.java index b664c7961f2..374e02dfb47 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/RetryPolicy.java +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryPolicy.java @@ -281,6 +281,11 @@ public interface RetryPolicy { * {@linkplain #maxDelay(Duration) max delay}. *

If a {@linkplain #multiplier(double) multiplier} is specified, it * is applied to the jitter value as well. + *

When the configured {@linkplain #delay(Duration) delay} is zero + * combined with a positive jitter, the delay never grows regardless of + * any configured multiplier, so the full configured jitter is applied + * directly as a random delay in the range from zero to + * {@code min(jitter, maxDelay)}. *

The default is no jitter. *

The supplied value will override any previously configured value. *

You should not specify this configuration option if you have diff --git a/spring-core/src/main/java/org/springframework/util/backoff/ExponentialBackOff.java b/spring-core/src/main/java/org/springframework/util/backoff/ExponentialBackOff.java index fc8b1e1022f..e60484b8d16 100644 --- a/spring-core/src/main/java/org/springframework/util/backoff/ExponentialBackOff.java +++ b/spring-core/src/main/java/org/springframework/util/backoff/ExponentialBackOff.java @@ -154,6 +154,10 @@ public class ExponentialBackOff implements BackOff { * {@code initialInterval} or above {@code maxInterval}. *

If a {@code multiplier} is specified, it is applied to the jitter value * as well. + *

When {@code initialInterval} is {@code 0} combined with a positive + * jitter, the interval never grows regardless of any configured multiplier, + * so the full configured jitter is applied directly as a random interval in + * the range from {@code 0} to {@code min(jitter, maxInterval)}. * @param jitter the jitter value in milliseconds * @since 7.0 */