diff --git a/src/main/java/org/springframework/data/neo4j/core/support/RetryExceptionPredicate.java b/src/main/java/org/springframework/data/neo4j/core/support/RetryExceptionPredicate.java new file mode 100644 index 000000000..2b0c76041 --- /dev/null +++ b/src/main/java/org/springframework/data/neo4j/core/support/RetryExceptionPredicate.java @@ -0,0 +1,66 @@ +/* + * Copyright 2011-2020 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.data.neo4j.core.support; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.function.Predicate; + +import org.apiguardian.api.API; +import org.neo4j.driver.exceptions.ServiceUnavailableException; +import org.neo4j.driver.exceptions.SessionExpiredException; +import org.neo4j.driver.exceptions.TransientException; +import org.springframework.dao.TransientDataAccessResourceException; + +/** + * A predicate indicating {@literal true} for {@link Throwable throwables} that can be safely retried and {@literal false} + * in any other case. This predicate can be used for example with Resilience4j. + * + * @author Michael J. Simons + * @soundtrack The Kleptones - 24 Hours + * @since 6.0 + */ +@API(status = API.Status.STABLE, since = "6.0") +public final class RetryExceptionPredicate implements Predicate { + + private static final Set RETRYABLE_ILLEGAL_STATE_MESSAGES = Collections.unmodifiableSet(new HashSet<>( + Arrays.asList("Transaction must be open, but has already been closed.", + "Session must be open, but has already been closed."))); + + @Override + public boolean test(Throwable throwable) { + + if (throwable instanceof IllegalStateException) { + String msg = throwable.getMessage(); + return RETRYABLE_ILLEGAL_STATE_MESSAGES.contains(msg); + } + + Throwable ex = throwable; + if (throwable instanceof TransientDataAccessResourceException) { + ex = throwable.getCause(); + } + + if (ex instanceof TransientException) { + String code = ((TransientException) ex).code(); + return !("Neo.TransientError.Transaction.Terminated".equals(code) || + "Neo.TransientError.Transaction.LockClientStopped".equals(code)); + } else { + return ex instanceof SessionExpiredException || ex instanceof ServiceUnavailableException; + } + } +} diff --git a/src/test/java/org/springframework/data/neo4j/core/support/RetryExceptionPredicateTest.java b/src/test/java/org/springframework/data/neo4j/core/support/RetryExceptionPredicateTest.java new file mode 100644 index 000000000..332b28615 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/core/support/RetryExceptionPredicateTest.java @@ -0,0 +1,75 @@ +/* + * Copyright 2011-2020 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.data.neo4j.core.support; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.junit.platform.commons.util.ReflectionUtils; +import org.neo4j.driver.exceptions.ServiceUnavailableException; +import org.neo4j.driver.exceptions.SessionExpiredException; +import org.springframework.dao.TransientDataAccessResourceException; + +/** + * @author Michael J. Simons + * @soundtrack Die Toten Hosen - Opium fürs Volk + */ +class RetryExceptionPredicateTest { + + @ParameterizedTest + @ValueSource(strings = { "Transaction must be open, but has already been closed.", + "Session must be open, but has already been closed." }) + void shouldRetryOnSomeIllegalStateExceptions(String msg) { + + RetryExceptionPredicate predicate = new RetryExceptionPredicate(); + assertThat(predicate.test(new IllegalStateException(msg))).isTrue(); + } + + @Test + void shouldNotRetryOnRandomIllegalStateExceptions() { + + RetryExceptionPredicate predicate = new RetryExceptionPredicate(); + assertThat(predicate.test(new IllegalStateException())).isFalse(); + } + + @ParameterizedTest + @ValueSource(classes = { SessionExpiredException.class, ServiceUnavailableException.class }) + void shouldRetryOnObviousRetryableExceptions(Class typeOfException) { + + RetryExceptionPredicate predicate = new RetryExceptionPredicate(); + assertThat(predicate.test(ReflectionUtils.newInstance(typeOfException, "msg"))).isTrue(); + } + + @ParameterizedTest + @ValueSource(classes = { TransientDataAccessResourceException.class, NullPointerException.class }) + void shouldNotRetryOnRandomExceptions() { + + RetryExceptionPredicate predicate = new RetryExceptionPredicate(); + assertThat(predicate.test(new IllegalStateException())).isFalse(); + } + + @Test + void shouldExtractCause() { + + TransientDataAccessResourceException ex = new TransientDataAccessResourceException("msg", + new ServiceUnavailableException("msg")); + + RetryExceptionPredicate predicate = new RetryExceptionPredicate(); + assertThat(predicate.test(ex)).isTrue(); + } +}