Introduce minimal retry functionality as a core framework feature

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

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

View File

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

View File

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

View File

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

View File

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

View File

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