diff --git a/spring-core/src/main/java/org/springframework/core/retry/BackOffPolicy.java b/spring-core/src/main/java/org/springframework/core/retry/BackOffPolicy.java new file mode 100644 index 000000000000..f49ad2ecc9ca --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/BackOffPolicy.java @@ -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(); + +} 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..5f63a0162595 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryCallback.java @@ -0,0 +1,42 @@ +/* + * 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 + */ +public interface RetryCallback { + + /** + * Method to execute and retry if needed. + * @return the result of the callback + * @throws Exception if an error occurs during the execution of the callback + */ + R run() throws Exception; + + /** + * A unique logical name for this callback to distinguish retries around + * business operations. + * @return the name of the callback + */ + String 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/RetryListener.java b/spring-core/src/main/java/org/springframework/core/retry/RetryListener.java new file mode 100644 index 000000000000..697184345373 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryListener.java @@ -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. + * + *

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 the type of the result + */ + default 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) { + } + +} 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..55734a59cd04 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryOperations.java @@ -0,0 +1,44 @@ +/* + * 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 if the retry policy is exhausted + */ + @Nullable 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..523bd8a35715 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryPolicy.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 java.util.function.Predicate; + +/** + * Strategy interface to define how to calculate the maximum number of retry attempts + * and which exceptions to retry. + * + * @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(); + + /** + * Return a predicate that specifies which exceptions to retry. Defaults to a + * predicate that retries all exceptions. + * @return a predicate that specifies which exceptions to retry + */ + default Predicate retryOn() { + return exception -> true; + } + +} 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..5808861fcd04 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java @@ -0,0 +1,171 @@ +/* + * 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 (MaxAttemptsRetryPolicy(3)) with a fixed backoff + * of 1 second (FixedBackOffPolicy(Duration.ofSeconds(1))). + * + *

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 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() { + }; + + /** + * 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 BackOffPolicy}. + * @param retryPolicy the retry policy to use + * @param backOffPolicy the backoff policy to use + */ + public RetryTemplate(RetryPolicy retryPolicy, BackOffPolicy backOffPolicy) { + this(retryPolicy); + Assert.notNull(backOffPolicy, "BackOff policy must not be null"); + this.backOffPolicy = backOffPolicy; + } + + /** + * Set the {@link RetryPolicy} to use. Defaults to MaxAttemptsRetryPolicy(3). + * @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 BackOffPolicy} to use. Defaults to FixedBackOffPolicy(Duration.ofSeconds(1)). + * @param backOffPolicy the backoff policy to use. Must not be null. + */ + 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 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, the last exception will + * be propagated to the caller. + * @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 + */ + @Override + public @Nullable R execute(RetryCallback retryCallback) throws RetryException { + Assert.notNull(retryCallback, "Retry Callback must not be null"); + int attempts = 0; + int maxAttempts = this.retryPolicy.getMaxAttempts(); + while (attempts++ <= maxAttempts) { + String callbackName = retryCallback.getName(); + if (logger.isDebugEnabled()) { + logger.debug("Retry callback '" + callbackName + "' attempt #" + attempts); + } + try { + this.retryListener.beforeRetry(); + R result = retryCallback.run(); + this.retryListener.onSuccess(result); + if (logger.isDebugEnabled()) { + logger.debug("Retry callback '" + callbackName + "' attempt #" + attempts + " succeeded"); + } + return result; + } + catch (Exception exception) { + if (!this.retryPolicy.retryOn().test(exception)) { + if (logger.isDebugEnabled()) { + logger.debug("Retry callback '" + callbackName + "' aborted on " + exception.getMessage(), exception); + } + break; + } + this.retryListener.onFailure(exception); + Duration duration = this.backOffPolicy.backOff(); + if (logger.isDebugEnabled()) { + logger.debug("Retry callback '" + callbackName + "' attempt #" + attempts + " failed, backing off for " + duration.toMillis() + "ms"); + } + try { + Thread.sleep(duration.toMillis()); + } + catch (InterruptedException interruptedException) { + throw new RetryException("Unable to backoff for retry callback '" + callbackName + "'", interruptedException); + } + if (attempts >= maxAttempts) { + this.retryListener.onMaxAttempts(exception); + throw new RetryException("Retry callback '" + callbackName + "' exceeded maximum retry attempts " + attempts + ", aborting execution", exception); + } + } + } + return null; + } + +} 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/AlwaysRetryPolicy.java b/spring-core/src/main/java/org/springframework/core/retry/support/AlwaysRetryPolicy.java new file mode 100644 index 000000000000..69e831cf6fff --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/support/AlwaysRetryPolicy.java @@ -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; + } + +} diff --git a/spring-core/src/main/java/org/springframework/core/retry/support/MaxAttemptsRetryPolicy.java b/spring-core/src/main/java/org/springframework/core/retry/support/MaxAttemptsRetryPolicy.java new file mode 100644 index 000000000000..91bfa45b4f85 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/support/MaxAttemptsRetryPolicy.java @@ -0,0 +1,46 @@ +/* + * 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; +import org.springframework.util.Assert; + +/** + * A {@link RetryPolicy} that signals to the caller to retry up to a maximum number of attempts. + * + * @author Mahmoud Ben Hassine + * @since 7.0 + */ +public class MaxAttemptsRetryPolicy implements RetryPolicy { + + private final int maxAttempts; + + /** + * Create a new {@link MaxAttemptsRetryPolicy}. + * @param maxAttempts the maximum number of retry attempts. Must be greater than 0. + */ + public MaxAttemptsRetryPolicy(int maxAttempts) { + Assert.isTrue(maxAttempts > 0, "Max attempts must be greater than zero"); + this.maxAttempts = maxAttempts; + } + + @Override + public int getMaxAttempts() { + return this.maxAttempts; + } + +} diff --git a/spring-core/src/main/java/org/springframework/core/retry/support/NeverRetryPolicy.java b/spring-core/src/main/java/org/springframework/core/retry/support/NeverRetryPolicy.java new file mode 100644 index 000000000000..515e49a36c7b --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/support/NeverRetryPolicy.java @@ -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 never retry the callback (useful for testing). + * + * @author Mahmoud Ben Hassine + * @since 7.0 + */ +public class NeverRetryPolicy implements RetryPolicy { + + @Override + public int getMaxAttempts() { + return 0; + } + +} diff --git a/spring-core/src/main/java/org/springframework/core/retry/support/backoff/ExponentialBackOffPolicy.java b/spring-core/src/main/java/org/springframework/core/retry/support/backoff/ExponentialBackOffPolicy.java new file mode 100644 index 000000000000..927609c12550 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/support/backoff/ExponentialBackOffPolicy.java @@ -0,0 +1,39 @@ +/* + * 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.backoff; + +import java.time.Duration; + +import org.springframework.core.retry.BackOffPolicy; + +/** + * A {@link BackOffPolicy} that grows exponentially, initially starting with 1 second. + * + * @author Mahmoud Ben Hassine + * @since 7.0 + */ +public class ExponentialBackOffPolicy implements BackOffPolicy { + + private Duration duration = Duration.ofSeconds(1); + + @Override + public Duration backOff() { + this.duration = this.duration.multipliedBy(2); + return this.duration; + } + +} diff --git a/spring-core/src/main/java/org/springframework/core/retry/support/backoff/FixedBackOffPolicy.java b/spring-core/src/main/java/org/springframework/core/retry/support/backoff/FixedBackOffPolicy.java new file mode 100644 index 000000000000..b0d028d35cb8 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/support/backoff/FixedBackOffPolicy.java @@ -0,0 +1,46 @@ +/* + * 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.backoff; + +import java.time.Duration; + +import org.springframework.core.retry.BackOffPolicy; + +/** + * A {@link BackOffPolicy} that is fixed to a configurable duration (defaults to 1 second). + * + * @author Mahmoud Ben Hassine + * @since 7.0 + */ +public class FixedBackOffPolicy implements BackOffPolicy { + + private Duration duration = Duration.ofSeconds(1); + + /** + * Create a new {@link FixedBackOffPolicy} with a specific duration. + * @param duration the fixed backoff duration + */ + public FixedBackOffPolicy(Duration duration) { + this.duration = duration; + } + + @Override + public Duration backOff() { + return this.duration; + } + +} diff --git a/spring-core/src/main/java/org/springframework/core/retry/support/backoff/ImmediateBackOffPolicy.java b/spring-core/src/main/java/org/springframework/core/retry/support/backoff/ImmediateBackOffPolicy.java new file mode 100644 index 000000000000..f03515d04e19 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/support/backoff/ImmediateBackOffPolicy.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.support.backoff; + +import java.time.Duration; + +import org.springframework.core.retry.BackOffPolicy; + +/** + * A {@link BackOffPolicy} that does not really backoff (might be useful for testing). + * + * @author Mahmoud Ben Hassine + * @since 7.0 + */ +public class ImmediateBackOffPolicy implements BackOffPolicy { + + @Override + public Duration backOff() { + return Duration.ZERO; + } + +} diff --git a/spring-core/src/main/java/org/springframework/core/retry/support/backoff/LinearBackOffPolicy.java b/spring-core/src/main/java/org/springframework/core/retry/support/backoff/LinearBackOffPolicy.java new file mode 100644 index 000000000000..983f32659826 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/support/backoff/LinearBackOffPolicy.java @@ -0,0 +1,39 @@ +/* + * 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.backoff; + +import java.time.Duration; + +import org.springframework.core.retry.BackOffPolicy; + +/** + * A {@link BackOffPolicy} that grows linearly, initially starting from zero. + * + * @author Mahmoud Ben Hassine + * @since 7.0 + */ +public class LinearBackOffPolicy implements BackOffPolicy { + + private Duration duration = Duration.ZERO; + + @Override + public Duration backOff() { + this.duration = this.duration.plusSeconds(1); + return this.duration; + } + +} diff --git a/spring-core/src/main/java/org/springframework/core/retry/support/backoff/package-info.java b/spring-core/src/main/java/org/springframework/core/retry/support/backoff/package-info.java new file mode 100644 index 000000000000..e7d70ce8c820 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/support/backoff/package-info.java @@ -0,0 +1,7 @@ +/** + * Support package for the core retry functionality containing common backoff policies. + */ +@NullMarked +package org.springframework.core.retry.support.backoff; + +import org.jspecify.annotations.NullMarked; diff --git a/spring-core/src/main/java/org/springframework/core/retry/support/listener/CompositeRetryListener.java b/spring-core/src/main/java/org/springframework/core/retry/support/listener/CompositeRetryListener.java new file mode 100644 index 000000000000..bf39187d83f6 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/support/listener/CompositeRetryListener.java @@ -0,0 +1,83 @@ +/* + * 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.listener; + +import java.util.LinkedList; +import java.util.List; + +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 + */ + public CompositeRetryListener(List listeners) { + this.listeners = 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() { + this.listeners.forEach(RetryListener::beforeRetry); + } + + @Override + public void onSuccess(T result) { + this.listeners.forEach(listener -> listener.onSuccess(result)); + } + + @Override + public void onFailure(Exception exception) { + this.listeners.forEach(listener -> listener.onFailure(exception)); + } + + @Override + public void onMaxAttempts(Exception exception) { + this.listeners.forEach(listener -> listener.onMaxAttempts(exception)); + } + +} diff --git a/spring-core/src/main/java/org/springframework/core/retry/support/listener/package-info.java b/spring-core/src/main/java/org/springframework/core/retry/support/listener/package-info.java new file mode 100644 index 000000000000..7e8a2b09498b --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/retry/support/listener/package-info.java @@ -0,0 +1,7 @@ +/** + * Support package for the core retry functionality containing listener utilities. + */ +@NullMarked +package org.springframework.core.retry.support.listener; + +import org.jspecify.annotations.NullMarked; 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..d434c0fbf669 --- /dev/null +++ b/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java @@ -0,0 +1,134 @@ +/* + * 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.function.Predicate; + +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.jupiter.api.Test; + +import org.springframework.core.retry.support.MaxAttemptsRetryPolicy; +import org.springframework.core.retry.support.backoff.FixedBackOffPolicy; + +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 MaxAttemptsRetryPolicy(3)); + retryTemplate.setBackOffPolicy(new FixedBackOffPolicy(Duration.ofMillis(100))); + + // when + String result = retryTemplate.execute(retryCallback); + + // then + assertThat(result).isEqualTo("hello world"); + } + + @Test + void testRetryWithFailure() { + // given + RetryCallback retryCallback = new RetryCallback<>() { + @Override + public String run() throws Exception { + throw new Exception("Error while invoking greeting service"); + } + + @Override + public String getName() { + return "greeting service"; + } + }; + RetryTemplate retryTemplate = new RetryTemplate(); + retryTemplate.setRetryPolicy(new MaxAttemptsRetryPolicy(3)); + retryTemplate.setBackOffPolicy(new FixedBackOffPolicy(Duration.ofMillis(100))); + + // when + ThrowingCallable throwingCallable = () -> retryTemplate.execute(retryCallback); + + // then + assertThatThrownBy(throwingCallable) + .isInstanceOf(RetryException.class) + .hasMessage("Retry callback 'greeting service' exceeded maximum retry attempts 3, aborting execution") + .cause().isInstanceOf(Exception.class).hasMessage("Error while invoking greeting service"); + } + + @Test + void testRetrySpecificException() { + // given + class TechnicalException extends Exception { + public TechnicalException(String message) { + super(message); + } + } + RetryCallback retryCallback = new RetryCallback<>() { + @Override + public String run() throws TechnicalException { + throw new TechnicalException("Error while invoking greeting service"); + } + + @Override + public String getName() { + return "greeting service"; + } + }; + MaxAttemptsRetryPolicy retryPolicy = new MaxAttemptsRetryPolicy(3) { + @Override + public Predicate retryOn() { + return exception -> exception instanceof TechnicalException; + } + }; + RetryTemplate retryTemplate = new RetryTemplate(); + retryTemplate.setRetryPolicy(retryPolicy); + retryTemplate.setBackOffPolicy(new FixedBackOffPolicy(Duration.ofMillis(100))); + + // when + ThrowingCallable throwingCallable = () -> retryTemplate.execute(retryCallback); + + // then + assertThatThrownBy(throwingCallable) + .isInstanceOf(RetryException.class) + .cause().isInstanceOf(TechnicalException.class) + .hasMessage("Error while invoking greeting service"); + } + +} diff --git a/spring-core/src/test/java/org/springframework/core/retry/support/AlwaysRetryPolicyTests.java b/spring-core/src/test/java/org/springframework/core/retry/support/AlwaysRetryPolicyTests.java new file mode 100644 index 000000000000..d0d59b3307e7 --- /dev/null +++ b/spring-core/src/test/java/org/springframework/core/retry/support/AlwaysRetryPolicyTests.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.support; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link AlwaysRetryPolicy}. + * + * @author Mahmoud Ben Hassine + */ +class AlwaysRetryPolicyTests { + + @Test + void testGetMaxAttempts() { + AlwaysRetryPolicy retryPolicy = new AlwaysRetryPolicy(); + + assertThat(retryPolicy.getMaxAttempts()).isEqualTo(Integer.MAX_VALUE); + } +} diff --git a/spring-core/src/test/java/org/springframework/core/retry/support/MaxAttemptsRetryPolicyTests.java b/spring-core/src/test/java/org/springframework/core/retry/support/MaxAttemptsRetryPolicyTests.java new file mode 100644 index 000000000000..644e66e920d5 --- /dev/null +++ b/spring-core/src/test/java/org/springframework/core/retry/support/MaxAttemptsRetryPolicyTests.java @@ -0,0 +1,44 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for {@link MaxAttemptsRetryPolicy}. + * + * @author Mahmoud Ben Hassine + */ +class MaxAttemptsRetryPolicyTests { + + @Test + void testGetMaxAttempts() { + int maxAttempts = 5; + MaxAttemptsRetryPolicy retryPolicy = new MaxAttemptsRetryPolicy(maxAttempts); + + assertThat(retryPolicy.getMaxAttempts()).isEqualTo(maxAttempts); + } + + @Test + void testInvalidGetMaxAttempts() { + assertThatThrownBy(() -> new MaxAttemptsRetryPolicy(0)) + .hasMessage("Max attempts must be greater than zero"); + } +} diff --git a/spring-core/src/test/java/org/springframework/core/retry/support/NeverRetryPolicyTests.java b/spring-core/src/test/java/org/springframework/core/retry/support/NeverRetryPolicyTests.java new file mode 100644 index 000000000000..df6e33248251 --- /dev/null +++ b/spring-core/src/test/java/org/springframework/core/retry/support/NeverRetryPolicyTests.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.support; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link NeverRetryPolicy}. + * + * @author Mahmoud Ben Hassine + */ +class NeverRetryPolicyTests { + + @Test + void testGetMaxAttempts() { + NeverRetryPolicy retryPolicy = new NeverRetryPolicy(); + + assertThat(retryPolicy.getMaxAttempts()).isEqualTo(0); + } +} diff --git a/spring-core/src/test/java/org/springframework/core/retry/support/backoff/ExponentialBackOffPolicyTests.java b/spring-core/src/test/java/org/springframework/core/retry/support/backoff/ExponentialBackOffPolicyTests.java new file mode 100644 index 000000000000..e6bccccb5566 --- /dev/null +++ b/spring-core/src/test/java/org/springframework/core/retry/support/backoff/ExponentialBackOffPolicyTests.java @@ -0,0 +1,42 @@ +/* + * 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.backoff; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ExponentialBackOffPolicy}. + * + * @author Mahmoud Ben Hassine + */ +class ExponentialBackOffPolicyTests { + + @Test + void testExponentialBackOff() { + ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy(); + + assertThat(backOffPolicy.backOff()).isEqualTo(Duration.ofSeconds(2)); + assertThat(backOffPolicy.backOff()).isEqualTo(Duration.ofSeconds(4)); + assertThat(backOffPolicy.backOff()).isEqualTo(Duration.ofSeconds(8)); + assertThat(backOffPolicy.backOff()).isEqualTo(Duration.ofSeconds(16)); + assertThat(backOffPolicy.backOff()).isEqualTo(Duration.ofSeconds(32)); + } +} diff --git a/spring-core/src/test/java/org/springframework/core/retry/support/backoff/FixedBackOffPolicyTests.java b/spring-core/src/test/java/org/springframework/core/retry/support/backoff/FixedBackOffPolicyTests.java new file mode 100644 index 000000000000..287ade221904 --- /dev/null +++ b/spring-core/src/test/java/org/springframework/core/retry/support/backoff/FixedBackOffPolicyTests.java @@ -0,0 +1,41 @@ +/* + * 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.backoff; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link FixedBackOffPolicy}. + * + * @author Mahmoud Ben Hassine + */ +class FixedBackOffPolicyTests { + + @Test + void testFixedBackOff() { + Duration duration = Duration.ofSeconds(3); + FixedBackOffPolicy backOffPolicy = new FixedBackOffPolicy(duration); + + assertThat(backOffPolicy.backOff()).isEqualTo(duration); + assertThat(backOffPolicy.backOff()).isEqualTo(duration); + assertThat(backOffPolicy.backOff()).isEqualTo(duration); + } +} diff --git a/spring-core/src/test/java/org/springframework/core/retry/support/backoff/ImmediateOffPolicyTests.java b/spring-core/src/test/java/org/springframework/core/retry/support/backoff/ImmediateOffPolicyTests.java new file mode 100644 index 000000000000..9fe6bc3458f3 --- /dev/null +++ b/spring-core/src/test/java/org/springframework/core/retry/support/backoff/ImmediateOffPolicyTests.java @@ -0,0 +1,38 @@ +/* + * 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.backoff; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ImmediateBackOffPolicy}. + * + * @author Mahmoud Ben Hassine + */ +class ImmediateOffPolicyTests { + + @Test + void testImmediateBackOff() { + ImmediateBackOffPolicy backOffPolicy = new ImmediateBackOffPolicy(); + + assertThat(backOffPolicy.backOff()).isEqualTo(Duration.ZERO); + } +} diff --git a/spring-core/src/test/java/org/springframework/core/retry/support/backoff/LinearBackOffPolicyTests.java b/spring-core/src/test/java/org/springframework/core/retry/support/backoff/LinearBackOffPolicyTests.java new file mode 100644 index 000000000000..1abe15538544 --- /dev/null +++ b/spring-core/src/test/java/org/springframework/core/retry/support/backoff/LinearBackOffPolicyTests.java @@ -0,0 +1,40 @@ +/* + * 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.backoff; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link LinearBackOffPolicy}. + * + * @author Mahmoud Ben Hassine + */ +class LinearBackOffPolicyTests { + + @Test + void testLinearBackOff() { + LinearBackOffPolicy backOffPolicy = new LinearBackOffPolicy(); + + assertThat(backOffPolicy.backOff()).isEqualTo(Duration.ofSeconds(1)); + assertThat(backOffPolicy.backOff()).isEqualTo(Duration.ofSeconds(2)); + assertThat(backOffPolicy.backOff()).isEqualTo(Duration.ofSeconds(3)); + } +} diff --git a/spring-core/src/test/java/org/springframework/core/retry/support/listener/ComposedRetryListenerTests.java b/spring-core/src/test/java/org/springframework/core/retry/support/listener/ComposedRetryListenerTests.java new file mode 100644 index 000000000000..3ad293e5a473 --- /dev/null +++ b/spring-core/src/test/java/org/springframework/core/retry/support/listener/ComposedRetryListenerTests.java @@ -0,0 +1,74 @@ +/* + * 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.listener; + +import java.util.Arrays; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import org.springframework.core.retry.RetryListener; + +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link CompositeRetryListener}. + * + * @author Mahmoud Ben Hassine + */ +class ComposedRetryListenerTests { + + private final RetryListener listener1 = Mockito.mock(RetryListener.class); + private final RetryListener listener2 = Mockito.mock(RetryListener.class); + + private final CompositeRetryListener composedRetryListener = new CompositeRetryListener(Arrays.asList(listener1, listener2)); + + @Test + void beforeRetry() { + this.composedRetryListener.beforeRetry(); + + verify(this.listener1).beforeRetry(); + verify(this.listener2).beforeRetry(); + } + + @Test + void onSuccess() { + Object result = new Object(); + this.composedRetryListener.onSuccess(result); + + verify(this.listener1).onSuccess(result); + verify(this.listener2).onSuccess(result); + } + + @Test + void onFailure() { + Exception exception = new Exception(); + this.composedRetryListener.onFailure(exception); + + verify(this.listener1).onFailure(exception); + verify(this.listener2).onFailure(exception); + } + + @Test + void onMaxAttempts() { + Exception exception = new Exception(); + this.composedRetryListener.onMaxAttempts(exception); + + verify(this.listener1).onMaxAttempts(exception); + verify(this.listener2).onMaxAttempts(exception); + } +}