diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryCallback.java b/spring-core/src/main/java/org/springframework/core/retry/RetryCallback.java new file mode 100644 index 000000000000..b538f17330ff --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryCallback.java @@ -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 the type of the result + * @see RetryOperations + */ +@FunctionalInterface +public interface RetryCallback { + + /** + * 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(); + } +} diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryException.java b/spring-core/src/main/java/org/springframework/core/retry/RetryException.java new file mode 100644 index 000000000000..facbf6b24a69 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryException.java @@ -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); + } + +} diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryExecution.java b/spring-core/src/main/java/org/springframework/core/retry/RetryExecution.java new file mode 100644 index 000000000000..13a10e272bb5 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryExecution.java @@ -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. + * + *

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); + +} diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryListener.java b/spring-core/src/main/java/org/springframework/core/retry/RetryListener.java new file mode 100644 index 000000000000..eb766b13ac31 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryListener.java @@ -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. + * + *

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) { + } + +} diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryOperations.java b/spring-core/src/main/java/org/springframework/core/retry/RetryOperations.java new file mode 100644 index 000000000000..cf3744b74c0f --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryOperations.java @@ -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. + * + *

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 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 execute(RetryCallback retryCallback) throws RetryException; + +} + 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 new file mode 100644 index 000000000000..16243a37377f --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryPolicy.java @@ -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(); + +} diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java b/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java new file mode 100644 index 000000000000..b03ebf2a72e9 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java @@ -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. + * + *

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.). + * + *

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 MaxAttemptsRetryPolicy(). + * @param retryPolicy the retry policy to use. Must not be null. + */ + 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 FixedBackOffPolicy(Duration.ofSeconds(1)). + * @param backOffPolicy the backoff policy to use. Must not be null. + */ + 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 NoOp implementation. + * If multiple listeners are needed, use a {@link CompositeRetryListener}. + * @param retryListener the retry listener to use. Must not be null. + */ + 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 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 execute(RetryCallback 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 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; + } + } + +} diff --git a/spring-core/src/main/java/org/springframework/core/retry/package-info.java b/spring-core/src/main/java/org/springframework/core/retry/package-info.java new file mode 100644 index 000000000000..9c7f8598c8e2 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/package-info.java @@ -0,0 +1,7 @@ +/** + * Main package for the core retry functionality. + */ +@NullMarked +package org.springframework.core.retry; + +import org.jspecify.annotations.NullMarked; diff --git a/spring-core/src/main/java/org/springframework/core/retry/support/CompositeRetryListener.java b/spring-core/src/main/java/org/springframework/core/retry/support/CompositeRetryListener.java new file mode 100644 index 000000000000..9fa958d7de6c --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/support/CompositeRetryListener.java @@ -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}. + * + *

Delegate listeners will be called in their registration order. + * + * @author Mahmoud Ben Hassine + * @since 7.0 + */ +public class CompositeRetryListener implements RetryListener { + + private final List 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 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 null. + */ + 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)); + } + +} diff --git a/spring-core/src/main/java/org/springframework/core/retry/support/MaxRetryAttemptsPolicy.java b/spring-core/src/main/java/org/springframework/core/retry/support/MaxRetryAttemptsPolicy.java new file mode 100644 index 000000000000..4d890f7c74e2 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/support/MaxRetryAttemptsPolicy.java @@ -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; + } + + } + +} diff --git a/spring-core/src/main/java/org/springframework/core/retry/support/MaxRetryDurationPolicy.java b/spring-core/src/main/java/org/springframework/core/retry/support/MaxRetryDurationPolicy.java new file mode 100644 index 000000000000..0b69f6cf8204 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/support/MaxRetryDurationPolicy.java @@ -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; + } + + } +} diff --git a/spring-core/src/main/java/org/springframework/core/retry/support/PredicateRetryPolicy.java b/spring-core/src/main/java/org/springframework/core/retry/support/PredicateRetryPolicy.java new file mode 100644 index 000000000000..100826d025dd --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/support/PredicateRetryPolicy.java @@ -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 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 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; + } + +} diff --git a/spring-core/src/main/java/org/springframework/core/retry/support/package-info.java b/spring-core/src/main/java/org/springframework/core/retry/support/package-info.java new file mode 100644 index 000000000000..598666fab6bd --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/support/package-info.java @@ -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; diff --git a/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java b/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java new file mode 100644 index 000000000000..a08c2ea8a6b0 --- /dev/null +++ b/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java @@ -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 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 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 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); + } + +} diff --git a/spring-core/src/test/java/org/springframework/core/retry/support/ComposedRetryListenerTests.java b/spring-core/src/test/java/org/springframework/core/retry/support/ComposedRetryListenerTests.java new file mode 100644 index 000000000000..440b08644a52 --- /dev/null +++ b/spring-core/src/test/java/org/springframework/core/retry/support/ComposedRetryListenerTests.java @@ -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); + } +} diff --git a/spring-core/src/test/java/org/springframework/core/retry/support/MaxRetryAttemptsPolicyTests.java b/spring-core/src/test/java/org/springframework/core/retry/support/MaxRetryAttemptsPolicyTests.java new file mode 100644 index 000000000000..02b7599f0f0a --- /dev/null +++ b/spring-core/src/test/java/org/springframework/core/retry/support/MaxRetryAttemptsPolicyTests.java @@ -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"); + } +} diff --git a/spring-core/src/test/java/org/springframework/core/retry/support/MaxRetryDurationPolicyTests.java b/spring-core/src/test/java/org/springframework/core/retry/support/MaxRetryDurationPolicyTests.java new file mode 100644 index 000000000000..bd3a0555cdea --- /dev/null +++ b/spring-core/src/test/java/org/springframework/core/retry/support/MaxRetryDurationPolicyTests.java @@ -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"); + } +} diff --git a/spring-core/src/test/java/org/springframework/core/retry/support/PredicateRetryPolicyTests.java b/spring-core/src/test/java/org/springframework/core/retry/support/PredicateRetryPolicyTests.java new file mode 100644 index 000000000000..50c54c7f04a8 --- /dev/null +++ b/spring-core/src/test/java/org/springframework/core/retry/support/PredicateRetryPolicyTests.java @@ -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 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(); + } + +}