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:
committed by
Sam Brannen
parent
aefdda3490
commit
3fb4a75ae4
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Main package for the core retry functionality.
|
||||
*/
|
||||
@NullMarked
|
||||
package org.springframework.core.retry;
|
||||
|
||||
import org.jspecify.annotations.NullMarked;
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user