Skip to content

Introduce minimal retry functionality as a core framework feature #34716

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* 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.time.Duration;

/**
* Strategy interface to define how to calculate the backoff policy.
*
* @author Mahmoud Ben Hassine
* @since 7.0
*/
public interface BackOffPolicy {

/**
* Signal how long to backoff before the next retry attempt.
* @return the duration to wait for before the next retry attempt
*/
Duration backOff();

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* 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.listener.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.
*/
default void beforeRetry() {
}

/**
* Called after a successful retry attempt.
* @param result the result of the callback
* @param <T> the type of the result
*/
default <T> void onSuccess(T result) {
}

/**
* Called every time a retry attempt fails.
* @param exception the exception thrown by the callback
*/
default void onFailure(Exception exception) {
}

/**
* Called once the retry policy is exhausted.
* @param exception the last exception thrown by the callback
*/
default void onMaxAttempts(Exception exception) {
}

}
Original file line number Diff line number Diff line change
@@ -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 java.util.concurrent.Callable;

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
*/
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 Exception if the retry policy is exhausted
*/
<R> @Nullable R execute(Callable<R> retryCallback) throws Exception;

}

Original file line number Diff line number Diff line change
@@ -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 how to calculate the maximin number of retry attempts.
*
* @author Mahmoud Ben Hassine
* @since 7.0
*/
public interface RetryPolicy {

/**
* Return the maximum number of retry attempts.
* @return the maximum number of retry attempts
*/
int getMaxAttempts();
Copy link
Member

@sbrannen sbrannen Apr 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the description of this PR, you state the following.

It is also focused only on stateless retries for now, but can be extended with other retry operations as well.

For future extension, do you envision extending the contract of RetryPolicy?

If so, how?

If not, how do you envision extending the feature set "with other retry operations"?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do you envision a user possibly being able to implement a retry policy conditionally based on the actual exception thrown?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I don't exclude extending the contract of RetryPolicy with anything that we think essential for this first iteration. The list of exceptions to retry/exclude could be part of the retry policy contract.

By "can be extended with other retry operations" I was referring to stateful retries. Stateful retries require some sort of context (similar to the one currently in Spring Retry). But instead of overwhelming the RetryOperations interface with both stateless and stateful operations, I envision stateful retries to be defined in a specialization of RetryOperations (something like StatefulRetryOperations with additional methods dealing with a context). I am trying to follow the ISP here, as most users won't probably need stateful retires, and therefore should not be forced to implement those methods. Do you agree?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed a change set that enriches the retry policy with a method that allows user to provide a predicate on the exception and specify the retry condition. I believe a predicate is more flexible than two lists of exceptions to include/exclude as it is currently in Spring Retry.


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*
* 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.time.Duration;
import java.util.concurrent.Callable;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.jspecify.annotations.Nullable;

import org.springframework.core.retry.support.MaxAttemptsRetryPolicy;
import org.springframework.core.retry.support.backoff.FixedBackOffPolicy;
import org.springframework.core.retry.support.listener.CompositeRetryListener;
import org.springframework.util.Assert;

/**
* A basic implementation of {@link RetryOperations} that uses a
* {@link RetryPolicy} and a {@link BackOffPolicy} to retry a
* {@link Callable} piece of code. By default, the callback will be called
* 3 times (<code>MaxAttemptsRetryPolicy(3)</code>) with a fixed backoff
* of 1 second (<code>FixedBackOffPolicy(Duration.ofSeconds(1))</code>).
*
* <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 BackOffPolicy
* @see RetryListener
*/
public class RetryTemplate implements RetryOperations {

protected final Log logger = LogFactory.getLog(getClass());

private RetryPolicy retryPolicy = new MaxAttemptsRetryPolicy(3);

private BackOffPolicy backOffPolicy = new FixedBackOffPolicy(Duration.ofSeconds(1));

private RetryListener retryListener = new RetryListener() {
};

/**
* Set the {@link RetryPolicy} to use. Defaults to <code>MaxAttemptsRetryPolicy(3)</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 BackOffPolicy} 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(BackOffPolicy backOffPolicy) {
Assert.notNull(backOffPolicy, "BackOff policy must not be null");
this.backOffPolicy = backOffPolicy;
}

/**
* Set a {@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, the last exception will
* be propagated to the caller.
* @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 Exception thrown if the retry policy is exhausted
*/
@Override
public <R> @Nullable R execute(Callable<R> retryCallback) throws Exception {
Assert.notNull(retryCallback, "Retry Callback must not be null");
int attempts = 0;
int maxAttempts = this.retryPolicy.getMaxAttempts();
while (attempts++ <= maxAttempts) {
if (logger.isDebugEnabled()) {
logger.debug("Retry attempt #" + attempts);
}
try {
this.retryListener.beforeRetry();
R result = retryCallback.call();
this.retryListener.onSuccess(result);
if (logger.isDebugEnabled()) {
logger.debug("Retry attempt #" + attempts + " succeeded");
}
return result;
}
catch (Exception exception) {
this.retryListener.onFailure(exception);
Duration duration = this.backOffPolicy.backOff();
Thread.sleep(duration.toMillis());
if (logger.isDebugEnabled()) {
logger.debug("Attempt #" + attempts + " failed, backing off for " + duration.toMillis() + "ms");
}
if (attempts >= maxAttempts) {
if (logger.isDebugEnabled()) {
logger.debug("Maximum retry attempts " + attempts + " exhausted, aborting execution");
}
this.retryListener.onMaxAttempts(exception);
throw exception;
}
}
}
return null;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Main package for the core retry functionality.
*/
@NullMarked
package org.springframework.core.retry;

import org.jspecify.annotations.NullMarked;
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* 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.RetryPolicy;

/**
* A {@link RetryPolicy} that signals to the caller to always retry the callback.
*
* @author Mahmoud Ben Hassine
* @since 7.0
*/
public class AlwaysRetryPolicy implements RetryPolicy {

@Override
public int getMaxAttempts() {
return Integer.MAX_VALUE;
}

}
Loading