Introduce minimal retry functionality as a core framework feature

This commit introduces a minimal core retry feature. It is inspired
by Spring Retry, but redesigned and trimmed to the bare minimum to
cover most cases.

Closes gh-34716
This commit is contained in:
Mahmoud Ben Hassine
2025-03-24 12:50:37 +01:00
committed by Sam Brannen
parent aefdda3490
commit 3fb4a75ae4
18 changed files with 1136 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.core.retry;
/**
* Callback interface for a retryable piece of code. Used in conjunction with {@link RetryOperations}.
*
* @author Mahmoud Ben Hassine
* @since 7.0
* @param <R> the type of the result
* @see RetryOperations
*/
@FunctionalInterface
public interface RetryCallback<R> {
/**
* Method to execute and retry if needed.
* @return the result of the callback
* @throws Throwable if an error occurs during the execution of the callback
*/
R run() throws Throwable;
/**
* A unique logical name for this callback to distinguish retries around
* business operations.
* @return the name of the callback. Defaults to the class name.
*/
default String getName() {
return getClass().getName();
}
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.core.retry;
import java.io.Serial;
/**
* Exception class for exhausted retries.
*
* @author Mahmoud Ben Hassine
* @since 7.0
* @see RetryOperations
*/
public class RetryException extends Exception {
@Serial
private static final long serialVersionUID = 5439915454935047936L;
/**
* Create a new exception with a message.
* @param message the exception's message
*/
public RetryException(String message) {
super(message);
}
/**
* Create a new exception with a message and a cause.
* @param message the exception's message
* @param cause the exception's cause
*/
public RetryException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.core.retry;
/**
* Strategy interface to define a retry execution.
*
* <p>Implementations may be stateful but do not need to be thread-safe.
*
* @author Mahmoud Ben Hassine
* @since 7.0
*/
public interface RetryExecution {
/**
* Specify if the operation should be retried based on the given throwable.
* @param throwable the exception that caused the operation to fail
* @return {@code true} if the operation should be retried, {@code false} otherwise
*/
boolean shouldRetry(Throwable throwable);
}

View File

@@ -0,0 +1,64 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.core.retry;
import org.springframework.core.retry.support.CompositeRetryListener;
/**
* An extension point that allows to inject code during key retry phases.
*
* <p>Typically registered in a {@link RetryTemplate}, and can be composed using
* a {@link CompositeRetryListener}.
*
* @author Mahmoud Ben Hassine
* @since 7.0
* @see CompositeRetryListener
*/
public interface RetryListener {
/**
* Called before every retry attempt.
* @param retryExecution the retry execution
*/
default void beforeRetry(RetryExecution retryExecution) {
}
/**
* Called after the first successful retry attempt.
* @param retryExecution the retry execution
* @param result the result of the callback
*/
default void onRetrySuccess(RetryExecution retryExecution, Object result) {
}
/**
* Called every time a retry attempt fails.
* @param retryExecution the retry execution
* @param throwable the throwable thrown by the callback
*/
default void onRetryFailure(RetryExecution retryExecution, Throwable throwable) {
}
/**
* Called once the retry policy is exhausted.
* @param retryExecution the retry execution
* @param throwable the last throwable thrown by the callback
*/
default void onRetryPolicyExhaustion(RetryExecution retryExecution, Throwable throwable) {
}
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.core.retry;
import org.jspecify.annotations.Nullable;
/**
* Main entry point to the core retry functionality. Defines a set of retryable operations.
*
* <p>Implemented by {@link RetryTemplate}. Not often used directly, but a useful
* option to enhance testability, as it can easily be mocked or stubbed.
*
* @author Mahmoud Ben Hassine
* @since 7.0
* @see RetryTemplate
*/
public interface RetryOperations {
/**
* Retry the given callback (according to the retry policy configured at the implementation level)
* until it succeeds or eventually throw an exception if the retry policy is exhausted.
* @param retryCallback the callback to call initially and retry if needed
* @param <R> the type of the callback's result
* @return the callback's result
* @throws RetryException thrown if the retry policy is exhausted. All attempt exceptions
* should be added as suppressed exceptions to the final exception.
*/
<R extends @Nullable Object> R execute(RetryCallback<R> retryCallback) throws RetryException;
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.core.retry;
/**
* Strategy interface to define a retry policy.
*
* @author Mahmoud Ben Hassine
* @since 7.0
*/
public interface RetryPolicy {
/**
* Start a new retry execution.
* @return a fresh {@link RetryExecution} ready to be used
*/
RetryExecution start();
}

View File

@@ -0,0 +1,178 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.core.retry;
import java.util.ArrayList;
import java.util.List;
import org.apache.commons.logging.LogFactory;
import org.jspecify.annotations.Nullable;
import org.springframework.core.log.LogAccessor;
import org.springframework.core.retry.support.CompositeRetryListener;
import org.springframework.core.retry.support.MaxRetryAttemptsPolicy;
import org.springframework.util.Assert;
import org.springframework.util.backoff.BackOff;
import org.springframework.util.backoff.BackOffExecution;
import org.springframework.util.backoff.FixedBackOff;
/**
* A basic implementation of {@link RetryOperations} that uses a
* {@link RetryPolicy} and a {@link BackOff} to retry a
* {@link RetryCallback}. By default, the callback will be called
* 3 times with a fixed backoff of 1 second.
*
* <p>It is also possible to register a {@link RetryListener} to intercept and inject code
* during key retry phases (before a retry attempt, after a retry attempt, etc.).
*
* <p>All retry operations performed by this class are logged at debug level,
* using "org.springframework.core.retry.RetryTemplate" as log category.
*
* @author Mahmoud Ben Hassine
* @since 7.0
* @see RetryOperations
* @see RetryPolicy
* @see BackOff
* @see RetryListener
*/
public class RetryTemplate implements RetryOperations {
protected final LogAccessor logger = new LogAccessor(LogFactory.getLog(getClass()));
protected RetryPolicy retryPolicy = new MaxRetryAttemptsPolicy();
protected BackOff backOffPolicy = new FixedBackOff();
protected RetryListener retryListener = new RetryListener() {
};
/**
* Create a new retry template with default settings.
*/
public RetryTemplate() {
}
/**
* Create a new retry template with a custom {@link RetryPolicy}.
* @param retryPolicy the retry policy to use
*/
public RetryTemplate(RetryPolicy retryPolicy) {
Assert.notNull(retryPolicy, "Retry policy must not be null");
this.retryPolicy = retryPolicy;
}
/**
* Create a new retry template with a custom {@link RetryPolicy} and {@link BackOff}.
* @param retryPolicy the retry policy to use
* @param backOffPolicy the backoff policy to use
*/
public RetryTemplate(RetryPolicy retryPolicy, BackOff backOffPolicy) {
this(retryPolicy);
Assert.notNull(backOffPolicy, "BackOff policy must not be null");
this.backOffPolicy = backOffPolicy;
}
/**
* Set the {@link RetryPolicy} to use. Defaults to <code>MaxAttemptsRetryPolicy()</code>.
* @param retryPolicy the retry policy to use. Must not be <code>null</code>.
*/
public void setRetryPolicy(RetryPolicy retryPolicy) {
Assert.notNull(retryPolicy, "Retry policy must not be null");
this.retryPolicy = retryPolicy;
}
/**
* Set the {@link BackOff} to use. Defaults to <code>FixedBackOffPolicy(Duration.ofSeconds(1))</code>.
* @param backOffPolicy the backoff policy to use. Must not be <code>null</code>.
*/
public void setBackOffPolicy(BackOff backOffPolicy) {
Assert.notNull(backOffPolicy, "BackOff policy must not be null");
this.backOffPolicy = backOffPolicy;
}
/**
* Set the {@link RetryListener} to use. Defaults to a <code>NoOp</code> implementation.
* If multiple listeners are needed, use a {@link CompositeRetryListener}.
* @param retryListener the retry listener to use. Must not be <code>null</code>.
*/
public void setRetryListener(RetryListener retryListener) {
Assert.notNull(retryListener, "Retry listener must not be null");
this.retryListener = retryListener;
}
/**
* Call the retry callback according to the configured retry and backoff policies.
* If the callback succeeds, its result is returned. Otherwise, a {@link RetryException}
* will be thrown to the caller having all attempt exceptions as suppressed exceptions.
* @param retryCallback the callback to call initially and retry if needed
* @param <R> the type of the result
* @return the result of the callback if any
* @throws RetryException thrown if the retry policy is exhausted. All attempt exceptions
* are added as suppressed exceptions to the final exception.
*/
@Override
public <R extends @Nullable Object> R execute(RetryCallback<R> retryCallback) throws RetryException {
Assert.notNull(retryCallback, "Retry Callback must not be null");
String callbackName = retryCallback.getName();
// initial attempt
try {
logger.debug(() -> "About to execute callback '" + callbackName + "'");
R result = retryCallback.run();
logger.debug(() -> "Callback '" + callbackName + "' executed successfully");
return result;
}
catch (Throwable initialException) {
logger.debug(initialException, () -> "Execution of callback '" + callbackName + "' failed, initiating the retry process");
// retry process starts here
RetryExecution retryExecution = this.retryPolicy.start();
BackOffExecution backOffExecution = this.backOffPolicy.start();
List<Throwable> suppressedExceptions = new ArrayList<>();
Throwable retryException = initialException;
while (retryExecution.shouldRetry(retryException)) {
logger.debug(() -> "About to retry callback '" + callbackName + "'");
try {
this.retryListener.beforeRetry(retryExecution);
R result = retryCallback.run();
this.retryListener.onRetrySuccess(retryExecution, result);
logger.debug(() -> "Callback '" + callbackName + "' retried successfully");
return result;
}
catch (Throwable currentAttemptException) {
this.retryListener.onRetryFailure(retryExecution, currentAttemptException);
try {
long duration = backOffExecution.nextBackOff();
logger.debug(() -> "Retry callback '" + callbackName + "' failed for " + currentAttemptException.getMessage() + ", backing off for " + duration + "ms");
Thread.sleep(duration);
}
catch (InterruptedException interruptedException) {
Thread.currentThread().interrupt();
throw new RetryException("Unable to backoff for retry callback '" + callbackName + "'", interruptedException);
}
suppressedExceptions.add(currentAttemptException);
retryException = currentAttemptException;
}
}
// retry policy exhausted at this point, throwing a RetryException with the initial exception as cause and remaining attempts exceptions as suppressed
RetryException finalException = new RetryException("Retry policy for callback '" + callbackName + "' exhausted, aborting execution", initialException);
suppressedExceptions.forEach(finalException::addSuppressed);
this.retryListener.onRetryPolicyExhaustion(retryExecution, finalException);
throw finalException;
}
}
}

View File

@@ -0,0 +1,7 @@
/**
* Main package for the core retry functionality.
*/
@NullMarked
package org.springframework.core.retry;
import org.jspecify.annotations.NullMarked;

View File

@@ -0,0 +1,85 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.core.retry.support;
import java.util.LinkedList;
import java.util.List;
import org.springframework.core.retry.RetryExecution;
import org.springframework.core.retry.RetryListener;
import org.springframework.core.retry.RetryTemplate;
import org.springframework.util.Assert;
/**
* A composite implementation of the {@link RetryListener} interface. This class
* is used to compose multiple listeners within a {@link RetryTemplate}.
*
* <p>Delegate listeners will be called in their registration order.
*
* @author Mahmoud Ben Hassine
* @since 7.0
*/
public class CompositeRetryListener implements RetryListener {
private final List<RetryListener> listeners;
/**
* Create a new {@link CompositeRetryListener}.
*/
public CompositeRetryListener() {
this.listeners = new LinkedList<>();
}
/**
* Create a new {@link CompositeRetryListener} with a list of delegates.
* @param listeners the delegate listeners to register. Must not be empty.
*/
public CompositeRetryListener(List<RetryListener> listeners) {
Assert.notEmpty(listeners, "RetryListener List must not be empty");
this.listeners = List.copyOf(listeners);
}
/**
* Add a new listener to the list of delegates.
* @param listener the listener to add. Must not be <code>null</code>.
*/
public void addListener(RetryListener listener) {
Assert.notNull(listener, "Retry listener must not be null");
this.listeners.add(listener);
}
@Override
public void beforeRetry(RetryExecution retryExecution) {
this.listeners.forEach(retryListener -> retryListener.beforeRetry(retryExecution));
}
@Override
public void onRetrySuccess(RetryExecution retryExecution, Object result) {
this.listeners.forEach(listener -> listener.onRetrySuccess(retryExecution, result));
}
@Override
public void onRetryFailure(RetryExecution retryExecution, Throwable throwable) {
this.listeners.forEach(listener -> listener.onRetryFailure(retryExecution, throwable));
}
@Override
public void onRetryPolicyExhaustion(RetryExecution retryExecution, Throwable throwable) {
this.listeners.forEach(listener -> listener.onRetryPolicyExhaustion(retryExecution, throwable));
}
}

View File

@@ -0,0 +1,84 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.core.retry.support;
import org.springframework.core.retry.RetryExecution;
import org.springframework.core.retry.RetryPolicy;
import org.springframework.util.Assert;
/**
* A {@link RetryPolicy} based on a number of attempts that should not exceed a maximum number.
*
* @author Mahmoud Ben Hassine
* @since 7.0
*/
public class MaxRetryAttemptsPolicy implements RetryPolicy {
/**
* The default maximum number of retry attempts.
*/
public static final int DEFAULT_MAX_RETRY_ATTEMPTS = 3;
private int maxRetryAttempts = DEFAULT_MAX_RETRY_ATTEMPTS;
/**
* Create a new {@link MaxRetryAttemptsPolicy} with the default maximum number of retry attempts.
*/
public MaxRetryAttemptsPolicy() {
}
/**
* Create a new {@link MaxRetryAttemptsPolicy} with the specified maximum number of retry attempts.
* @param maxRetryAttempts the maximum number of retry attempts. Must be greater than zero.
*/
public MaxRetryAttemptsPolicy(int maxRetryAttempts) {
setMaxRetryAttempts(maxRetryAttempts);
}
/**
* Start a new retry execution.
* @return a fresh {@link MaxRetryAttemptsPolicyExecution} ready to be used
*/
@Override
public RetryExecution start() {
return new MaxRetryAttemptsPolicyExecution();
}
/**
* Set the maximum number of retry attempts.
* @param maxRetryAttempts the maximum number of retry attempts. Must be greater than zero.
*/
public void setMaxRetryAttempts(int maxRetryAttempts) {
Assert.isTrue(maxRetryAttempts > 0, "Max retry attempts must be greater than zero");
this.maxRetryAttempts = maxRetryAttempts;
}
/**
* A {@link RetryExecution} based on a maximum number of retry attempts.
*/
private class MaxRetryAttemptsPolicyExecution implements RetryExecution {
private int retryAttempts;
@Override
public boolean shouldRetry(Throwable throwable) {
return this.retryAttempts++ < MaxRetryAttemptsPolicy.this.maxRetryAttempts;
}
}
}

View File

@@ -0,0 +1,88 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.core.retry.support;
import java.time.Duration;
import java.time.LocalDateTime;
import org.springframework.core.retry.RetryExecution;
import org.springframework.core.retry.RetryPolicy;
import org.springframework.util.Assert;
/**
* A {@link RetryPolicy} based on a timeout.
*
* @author Mahmoud Ben Hassine
* @since 7.0
*/
public class MaxRetryDurationPolicy implements RetryPolicy {
/**
* The default maximum retry duration.
*/
public static final Duration DEFAULT_MAX_RETRY_DURATION = Duration.ofSeconds(3);
private Duration maxRetryDuration = DEFAULT_MAX_RETRY_DURATION;
/**
* Create a new {@link MaxRetryDurationPolicy} with the default maximum retry duration.
*/
public MaxRetryDurationPolicy() {
}
/**
* Create a new {@link MaxRetryDurationPolicy} with the specified maximum retry duration.
* @param maxRetryDuration the maximum retry duration. Must be positive.
*/
public MaxRetryDurationPolicy(Duration maxRetryDuration) {
setMaxRetryDuration(maxRetryDuration);
}
/**
* Start a new retry execution.
* @return a fresh {@link MaxRetryDurationPolicyExecution} ready to be used
*/
@Override
public RetryExecution start() {
return new MaxRetryDurationPolicyExecution();
}
/**
* Set the maximum retry duration.
* @param maxRetryDuration the maximum retry duration. Must be positive.
*/
public void setMaxRetryDuration(Duration maxRetryDuration) {
Assert.isTrue(!maxRetryDuration.isNegative() && !maxRetryDuration.isZero(),
"Max retry duration must be positive");
this.maxRetryDuration = maxRetryDuration;
}
/**
* A {@link RetryExecution} based on a maximum retry duration.
*/
private class MaxRetryDurationPolicyExecution implements RetryExecution {
private final LocalDateTime retryStartTime = LocalDateTime.now();
@Override
public boolean shouldRetry(Throwable throwable) {
Duration currentRetryDuration = Duration.between(this.retryStartTime, LocalDateTime.now());
return currentRetryDuration.compareTo(MaxRetryDurationPolicy.this.maxRetryDuration) <= 0;
}
}
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.core.retry.support;
import java.util.function.Predicate;
import org.springframework.core.retry.RetryExecution;
import org.springframework.core.retry.RetryPolicy;
/**
* A {@link RetryPolicy} based on a predicate.
*
* @author Mahmoud Ben Hassine
* @since 7.0
*/
public class PredicateRetryPolicy implements RetryPolicy {
private final Predicate<Throwable> predicate;
/**
* Create a new {@link PredicateRetryPolicy} with the given predicate.
* @param predicate the predicate to use for determining whether to retry the exception or not
*/
public PredicateRetryPolicy(Predicate<Throwable> predicate) {
this.predicate = predicate;
}
/**
* Start a new retry execution.
* @return a fresh {@link RetryExecution} ready to be used
*/
public RetryExecution start() {
return this.predicate::test;
}
}

View File

@@ -0,0 +1,7 @@
/**
* Support package for the core retry functionality containing common retry policies.
*/
@NullMarked
package org.springframework.core.retry.support;
import org.jspecify.annotations.NullMarked;

View File

@@ -0,0 +1,141 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.core.retry;
import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
import org.junit.jupiter.api.Test;
import org.springframework.core.retry.support.MaxRetryAttemptsPolicy;
import org.springframework.util.backoff.FixedBackOff;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Tests for {@link RetryTemplate}.
*
* @author Mahmoud Ben Hassine
*/
class RetryTemplateTests {
@Test
void testRetryWithSuccess() throws Exception {
// given
RetryCallback<String> retryCallback = new RetryCallback<>() {
int failure;
@Override
public String run() throws Exception {
if (failure++ < 2) {
throw new Exception("Error while invoking greeting service");
}
return "hello world";
}
@Override
public String getName() {
return "greeting service";
}
};
RetryTemplate retryTemplate = new RetryTemplate();
retryTemplate.setRetryPolicy(new MaxRetryAttemptsPolicy());
retryTemplate.setBackOffPolicy(new FixedBackOff());
// when
String result = retryTemplate.execute(retryCallback);
// then
assertThat(result).isEqualTo("hello world");
}
@Test
void testRetryWithFailure() {
// given
Exception exception = new Exception("Error while invoking greeting service");
RetryCallback<String> retryCallback = new RetryCallback<>() {
@Override
public String run() throws Exception {
throw exception;
}
@Override
public String getName() {
return "greeting service";
}
};
RetryTemplate retryTemplate = new RetryTemplate();
retryTemplate.setRetryPolicy(new MaxRetryAttemptsPolicy());
retryTemplate.setBackOffPolicy(new FixedBackOff());
// when
ThrowingCallable throwingCallable = () -> retryTemplate.execute(retryCallback);
// then
assertThatThrownBy(throwingCallable)
.isInstanceOf(RetryException.class)
.hasMessage("Retry policy for callback 'greeting service' exhausted, aborting execution")
.hasCause(exception);
}
@Test
void testRetrySpecificException() {
// given
class TechnicalException extends Exception {
@java.io.Serial
private static final long serialVersionUID = 1L;
public TechnicalException(String message) {
super(message);
}
}
final TechnicalException technicalException = new TechnicalException("Error while invoking greeting service");
RetryCallback<String> retryCallback = new RetryCallback<>() {
@Override
public String run() throws TechnicalException {
throw technicalException;
}
@Override
public String getName() {
return "greeting service";
}
};
MaxRetryAttemptsPolicy retryPolicy = new MaxRetryAttemptsPolicy() {
@Override
public RetryExecution start() {
return new RetryExecution() {
int retryAttempts;
@Override
public boolean shouldRetry(Throwable throwable) {
return this.retryAttempts++ < 3 && throwable instanceof TechnicalException;
}
};
}
};
RetryTemplate retryTemplate = new RetryTemplate();
retryTemplate.setRetryPolicy(retryPolicy);
retryTemplate.setBackOffPolicy(new FixedBackOff());
// when
ThrowingCallable throwingCallable = () -> retryTemplate.execute(retryCallback);
// then
assertThatThrownBy(throwingCallable)
.isInstanceOf(RetryException.class)
.hasMessage("Retry policy for callback 'greeting service' exhausted, aborting execution")
.hasCause(technicalException);
}
}

View File

@@ -0,0 +1,79 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.core.retry.support;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
import org.springframework.core.retry.RetryExecution;
import org.springframework.core.retry.RetryListener;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* Tests for {@link CompositeRetryListener}.
*
* @author Mahmoud Ben Hassine
*/
class ComposedRetryListenerTests {
private final RetryListener listener1 = mock();
private final RetryListener listener2 = mock();
private final CompositeRetryListener composedRetryListener = new CompositeRetryListener(Arrays.asList(listener1, listener2));
@Test
void beforeRetry() {
RetryExecution retryExecution = mock();
this.composedRetryListener.beforeRetry(retryExecution);
verify(this.listener1).beforeRetry(retryExecution);
verify(this.listener2).beforeRetry(retryExecution);
}
@Test
void onSuccess() {
Object result = new Object();
RetryExecution retryExecution = mock();
this.composedRetryListener.onRetrySuccess(retryExecution, result);
verify(this.listener1).onRetrySuccess(retryExecution, result);
verify(this.listener2).onRetrySuccess(retryExecution, result);
}
@Test
void onFailure() {
Exception exception = new Exception();
RetryExecution retryExecution = mock();
this.composedRetryListener.onRetryFailure(retryExecution, exception);
verify(this.listener1).onRetryFailure(retryExecution, exception);
verify(this.listener2).onRetryFailure(retryExecution, exception);
}
@Test
void onMaxAttempts() {
Exception exception = new Exception();
RetryExecution retryExecution = mock();
this.composedRetryListener.onRetryPolicyExhaustion(retryExecution, exception);
verify(this.listener1).onRetryPolicyExhaustion(retryExecution, exception);
verify(this.listener2).onRetryPolicyExhaustion(retryExecution, exception);
}
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.core.retry.support;
import org.junit.jupiter.api.Test;
import org.springframework.core.retry.RetryExecution;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link MaxRetryAttemptsPolicy}.
*
* @author Mahmoud Ben Hassine
*/
class MaxRetryAttemptsPolicyTests {
@Test
void testDefaultMaxRetryAttempts() {
// given
MaxRetryAttemptsPolicy retryPolicy = new MaxRetryAttemptsPolicy();
Throwable throwable = mock();
// when
RetryExecution retryExecution = retryPolicy.start();
// then
assertThat(retryExecution.shouldRetry(throwable)).isTrue();
assertThat(retryExecution.shouldRetry(throwable)).isTrue();
assertThat(retryExecution.shouldRetry(throwable)).isTrue();
assertThat(retryExecution.shouldRetry(throwable)).isFalse();
}
@Test
void testInvalidMaxRetryAttempts() {
assertThatThrownBy(() -> new MaxRetryAttemptsPolicy(-1))
.hasMessage("Max retry attempts must be greater than zero");
}
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.core.retry.support;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Tests for {@link MaxRetryDurationPolicy}.
*
* @author Mahmoud Ben Hassine
*/
class MaxRetryDurationPolicyTests {
@Test
void testInvalidMaxRetryDuration() {
assertThatThrownBy(() -> new MaxRetryDurationPolicy(Duration.ZERO))
.hasMessage("Max retry duration must be positive");
}
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.core.retry.support;
import java.util.function.Predicate;
import org.junit.jupiter.api.Test;
import org.springframework.core.retry.RetryExecution;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link PredicateRetryPolicy}.
*
* @author Mahmoud Ben Hassine
*/
class PredicateRetryPolicyTests {
@Test
void testPredicateRetryPolicy() {
// given
class MyException extends Exception {
@java.io.Serial
private static final long serialVersionUID = 1L;
}
Predicate<Throwable> predicate = throwable -> throwable instanceof MyException;
PredicateRetryPolicy retryPolicy = new PredicateRetryPolicy(predicate);
// when
RetryExecution retryExecution = retryPolicy.start();
// then
assertThat(retryExecution.shouldRetry(new MyException())).isTrue();
assertThat(retryExecution.shouldRetry(new IllegalStateException())).isFalse();
}
}