Skip to content

Commit b2cdfba

Browse files
committed
Introduce onRetryPolicyInterruption() callback in RetryListener
In RetryTemplate, if we encounter an InterruptedException while sleeping for the configured back-off duration, we throw a RetryException with the InterruptedException as the cause. However, prior to this commit, that RetryException propagated to the caller without notifying the registered RetryListener. To address that, this commit introduces a new onRetryPolicyInterruption() callback in RetryListener as a companion to the existing onRetryPolicyExhaustion() callback. Closes gh-35442
1 parent 13d36a5 commit b2cdfba

File tree

5 files changed

+72
-6
lines changed

5 files changed

+72
-6
lines changed

spring-core/src/main/java/org/springframework/core/retry/RetryListener.java

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ default void onRetrySuccess(RetryPolicy retryPolicy, Retryable<?> retryable, @Nu
5353
}
5454

5555
/**
56-
* Called every time a retry attempt fails.
56+
* Called after every failed retry attempt.
5757
* @param retryPolicy the {@link RetryPolicy}
5858
* @param retryable the {@link Retryable} operation
5959
* @param throwable the exception thrown by the {@code Retryable} operation
@@ -65,13 +65,29 @@ default void onRetryFailure(RetryPolicy retryPolicy, Retryable<?> retryable, Thr
6565
* Called if the {@link RetryPolicy} is exhausted.
6666
* @param retryPolicy the {@code RetryPolicy}
6767
* @param retryable the {@code Retryable} operation
68-
* @param exception the resulting {@link RetryException}, including the last operation
69-
* exception as a cause and all earlier operation exceptions as suppressed exceptions
68+
* @param exception the resulting {@link RetryException}, with the last
69+
* exception thrown by the {@link Retryable} operation as the cause and any
70+
* exceptions from previous attempts as suppressed exceptions
7071
* @see RetryException#getCause()
7172
* @see RetryException#getSuppressed()
7273
* @see RetryException#getRetryCount()
7374
*/
7475
default void onRetryPolicyExhaustion(RetryPolicy retryPolicy, Retryable<?> retryable, RetryException exception) {
7576
}
7677

78+
/**
79+
* Called if an {@link InterruptedException} is encountered while
80+
* {@linkplain Thread#sleep(long) sleeping} between retry attempts.
81+
* @param retryPolicy the {@code RetryPolicy}
82+
* @param retryable the {@code Retryable} operation
83+
* @param exception the resulting {@link RetryException}, with the
84+
* {@code InterruptedException} as the cause and any exceptions from previous
85+
* retry attempts as suppressed exceptions
86+
* @see RetryException#getCause()
87+
* @see RetryException#getSuppressed()
88+
* @see RetryException#getRetryCount()
89+
*/
90+
default void onRetryPolicyInterruption(RetryPolicy retryPolicy, Retryable<?> retryable, RetryException exception) {
91+
}
92+
7793
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ public RetryListener getRetryListener() {
168168
"Unable to back off for retryable operation '%s'".formatted(retryableName),
169169
interruptedException);
170170
exceptions.forEach(retryException::addSuppressed);
171+
this.retryListener.onRetryPolicyInterruption(this.retryPolicy, retryable, retryException);
171172
throw retryException;
172173
}
173174
logger.debug(() -> "Preparing to retry operation '%s'".formatted(retryableName));

spring-core/src/main/java/org/springframework/core/retry/support/CompositeRetryListener.java

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,14 @@
2929
import org.springframework.util.Assert;
3030

3131
/**
32-
* A composite implementation of the {@link RetryListener} interface.
33-
* Delegate listeners will be called in their registration order.
32+
* A composite implementation of the {@link RetryListener} interface, which is
33+
* used to compose multiple listeners within a {@link RetryTemplate}.
3434
*
35-
* <p>This class is used to compose multiple listeners within a {@link RetryTemplate}.
35+
* <p>Delegate listeners will be called in their registration order.
3636
*
3737
* @author Mahmoud Ben Hassine
3838
* @author Juergen Hoeller
39+
* @author Sam Brannen
3940
* @since 7.0
4041
*/
4142
public class CompositeRetryListener implements RetryListener {
@@ -88,4 +89,9 @@ public void onRetryPolicyExhaustion(RetryPolicy retryPolicy, Retryable<?> retrya
8889
this.listeners.forEach(listener -> listener.onRetryPolicyExhaustion(retryPolicy, retryable, exception));
8990
}
9091

92+
@Override
93+
public void onRetryPolicyInterruption(RetryPolicy retryPolicy, Retryable<?> retryable, RetryException exception) {
94+
this.listeners.forEach(listener -> listener.onRetryPolicyInterruption(retryPolicy, retryable, exception));
95+
}
96+
9197
}

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,11 @@
3030
import org.junit.jupiter.params.ParameterizedTest;
3131
import org.junit.jupiter.params.provider.Arguments.ArgumentSet;
3232
import org.junit.jupiter.params.provider.FieldSource;
33+
import org.junit.platform.commons.util.ExceptionUtils;
3334
import org.mockito.InOrder;
3435

36+
import org.springframework.util.backoff.BackOff;
37+
3538
import static org.assertj.core.api.Assertions.assertThat;
3639
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
3740
import static org.junit.jupiter.params.provider.Arguments.argumentSet;
@@ -213,6 +216,36 @@ public String getName() {
213216
verifyNoMoreInteractions(retryListener);
214217
}
215218

219+
@Test
220+
void retryWithInterruptionDuringSleep() {
221+
Exception exception = new RuntimeException("Boom!");
222+
InterruptedException interruptedException = new InterruptedException();
223+
224+
// Simulates interruption during sleep:
225+
BackOff backOff = () -> () -> {
226+
throw ExceptionUtils.throwAsUncheckedException(interruptedException);
227+
};
228+
229+
RetryPolicy retryPolicy = RetryPolicy.builder().backOff(backOff).build();
230+
RetryTemplate retryTemplate = new RetryTemplate(retryPolicy);
231+
retryTemplate.setRetryListener(retryListener);
232+
Retryable<String> retryable = () -> {
233+
throw exception;
234+
};
235+
236+
assertThatExceptionOfType(RetryException.class)
237+
.isThrownBy(() -> retryTemplate.execute(retryable))
238+
.withMessageMatching("Unable to back off for retryable operation '.+?'")
239+
.withCause(interruptedException)
240+
.satisfies(throwable -> assertThat(throwable.getSuppressed()).containsExactly(exception))
241+
// TODO Fix retry count for InterruptedException scenario.
242+
// Retry count should actually be 0.
243+
.satisfies(throwable -> assertThat(throwable.getRetryCount()).isEqualTo(1))
244+
.satisfies(throwable -> inOrder.verify(retryListener).onRetryPolicyInterruption(retryPolicy, retryable, throwable));
245+
246+
verifyNoMoreInteractions(retryListener);
247+
}
248+
216249
@Test
217250
void retryWithFailingRetryableAndMultiplePredicates() {
218251
var invocationCount = new AtomicInteger();

spring-core/src/test/java/org/springframework/core/retry/support/CompositeRetryListenerTests.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,14 @@ void onRetryPolicyExhaustion() {
9292
verify(listener3).onRetryPolicyExhaustion(retryPolicy, retryable, exception);
9393
}
9494

95+
@Test
96+
void onRetryPolicyInterruption() {
97+
RetryException exception = new RetryException("", new Exception());
98+
compositeRetryListener.onRetryPolicyInterruption(retryPolicy, retryable, exception);
99+
100+
verify(listener1).onRetryPolicyInterruption(retryPolicy, retryable, exception);
101+
verify(listener2).onRetryPolicyInterruption(retryPolicy, retryable, exception);
102+
verify(listener3).onRetryPolicyInterruption(retryPolicy, retryable, exception);
103+
}
104+
95105
}

0 commit comments

Comments
 (0)