GH-264: Enable Skipping Recovery
Resolves https://github.com/spring-projects/spring-retry/issues/264
This commit is contained in:
committed by
Artem Bilan
parent
fef3cfe06d
commit
860bd0db4b
18
README.md
18
README.md
@@ -597,6 +597,24 @@ When evaluating at runtime, a root object containing the method arguments is pas
|
||||
**Note:** The arguments are not available until the method has been called at least once; they will be null initially, which means, for example, you can't set the initial `maxAttempts` using an argument value, you can, however, change the `maxAttempts` after the first failure and before any retries are performed.
|
||||
Also, the arguments are only available when using stateless retry (which includes the `@CircuitBreaker`).
|
||||
|
||||
Version 2.0 adds more flexibility to exception classification.
|
||||
|
||||
```java
|
||||
@Retryable(retryFor = RuntimeException.class, noRetryFor = IllegalStateException.class, notRecoverable = {
|
||||
IllegalArgumentException.class, IllegalStateException.class })
|
||||
public void service() {
|
||||
...
|
||||
}
|
||||
|
||||
@Recover
|
||||
public void recover(Throwable cause) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
`retryFor` and `noRetryFor` are replacements of `include` and `exclude` properties, which are now deprecated.
|
||||
The new `notRecoverable` property allows the recovery method(s) to be skipped, even if one matches the exception type; the exception is thrown to the caller either after retries are exhausted, or immediately, if the exception is not retryable.
|
||||
|
||||
##### Examples
|
||||
|
||||
```java
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2006-2007 the original author or authors.
|
||||
* Copyright 2006-2022 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.
|
||||
@@ -55,6 +55,12 @@ public interface RetryContext extends AttributeAccessor {
|
||||
*/
|
||||
String EXHAUSTED = "context.exhausted";
|
||||
|
||||
/**
|
||||
* Retry context attribute that is non-null (and true) if the exception is not
|
||||
* recoverable.
|
||||
*/
|
||||
String NO_RECOVERY = "context.no-recovery";
|
||||
|
||||
/**
|
||||
* Signal to the framework that no more attempts should be made to try or retry the
|
||||
* current {@link RetryCallback}.
|
||||
|
||||
@@ -344,11 +344,11 @@ public class AnnotationAwareRetryOperationsInterceptor implements IntroductionIn
|
||||
boolean hasExpression = StringUtils.hasText(exceptionExpression);
|
||||
if (includes.length == 0) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Class<? extends Throwable>[] value = (Class<? extends Throwable>[]) attrs.get("include");
|
||||
Class<? extends Throwable>[] value = (Class<? extends Throwable>[]) attrs.get("retryFor");
|
||||
includes = value;
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
Class<? extends Throwable>[] excludes = (Class<? extends Throwable>[]) attrs.get("exclude");
|
||||
Class<? extends Throwable>[] excludes = (Class<? extends Throwable>[]) attrs.get("noRetryFor");
|
||||
Integer maxAttempts = (Integer) attrs.get("maxAttempts");
|
||||
String maxAttemptsExpression = (String) attrs.get("maxAttemptsExpression");
|
||||
Expression parsedExpression = null;
|
||||
@@ -360,8 +360,9 @@ public class AnnotationAwareRetryOperationsInterceptor implements IntroductionIn
|
||||
}
|
||||
}
|
||||
final Expression expression = parsedExpression;
|
||||
SimpleRetryPolicy simple = null;
|
||||
if (includes.length == 0 && excludes.length == 0) {
|
||||
SimpleRetryPolicy simple = hasExpression
|
||||
simple = hasExpression
|
||||
? new ExpressionRetryPolicy(resolve(exceptionExpression)).withBeanFactory(this.beanFactory)
|
||||
: new SimpleRetryPolicy();
|
||||
if (expression != null) {
|
||||
@@ -370,7 +371,6 @@ public class AnnotationAwareRetryOperationsInterceptor implements IntroductionIn
|
||||
else {
|
||||
simple.setMaxAttempts(maxAttempts);
|
||||
}
|
||||
return simple;
|
||||
}
|
||||
Map<Class<? extends Throwable>, Boolean> policyMap = new HashMap<>();
|
||||
for (Class<? extends Throwable> type : includes) {
|
||||
@@ -380,17 +380,24 @@ public class AnnotationAwareRetryOperationsInterceptor implements IntroductionIn
|
||||
policyMap.put(type, false);
|
||||
}
|
||||
boolean retryNotExcluded = includes.length == 0;
|
||||
if (hasExpression) {
|
||||
return new ExpressionRetryPolicy(maxAttempts, policyMap, true, exceptionExpression, retryNotExcluded)
|
||||
.withBeanFactory(this.beanFactory);
|
||||
}
|
||||
else {
|
||||
SimpleRetryPolicy policy = new SimpleRetryPolicy(maxAttempts, policyMap, true, retryNotExcluded);
|
||||
if (expression != null) {
|
||||
policy.setMaxAttempts(() -> evaluate(expression, Integer.class, stateless));
|
||||
if (simple == null) {
|
||||
if (hasExpression) {
|
||||
simple = new ExpressionRetryPolicy(maxAttempts, policyMap, true, resolve(exceptionExpression),
|
||||
retryNotExcluded).withBeanFactory(this.beanFactory);
|
||||
}
|
||||
else {
|
||||
simple = new SimpleRetryPolicy(maxAttempts, policyMap, true, retryNotExcluded);
|
||||
if (expression != null) {
|
||||
simple.setMaxAttempts(() -> evaluate(expression, Integer.class, stateless));
|
||||
}
|
||||
}
|
||||
return policy;
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
Class<? extends Throwable>[] noRecovery = (Class<? extends Throwable>[]) attrs.get("notRecoverable");
|
||||
if (noRecovery != null && noRecovery.length > 0) {
|
||||
simple.setNotRecoverable(noRecovery);
|
||||
}
|
||||
return simple;
|
||||
}
|
||||
|
||||
private BackOffPolicy getBackoffPolicy(Backoff backoff, boolean stateless) {
|
||||
|
||||
@@ -22,6 +22,8 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.core.annotation.AliasFor;
|
||||
|
||||
/**
|
||||
* Annotation for a method invocation that is retryable.
|
||||
*
|
||||
@@ -48,17 +50,51 @@ public @interface CircuitBreaker {
|
||||
* Exception types that are retryable. Defaults to empty (and if excludes is also
|
||||
* empty all exceptions are retried).
|
||||
* @return exception types to retry
|
||||
* @deprecated in favor of {@link #retryFor()}.
|
||||
*/
|
||||
@AliasFor("retryFor")
|
||||
@Deprecated
|
||||
Class<? extends Throwable>[] include() default {};
|
||||
|
||||
/**
|
||||
* Exception types that are retryable. Defaults to empty (and, if noRetryFor is also
|
||||
* empty, all exceptions are retried).
|
||||
* @return exception types to retry
|
||||
* @since 2.0
|
||||
*/
|
||||
@AliasFor("include")
|
||||
Class<? extends Throwable>[] retryFor() default {};
|
||||
|
||||
/**
|
||||
* Exception types that are not retryable. Defaults to empty (and if includes is also
|
||||
* empty all exceptions are retried). If includes is empty but excludes is not, all
|
||||
* not excluded exceptions are retried
|
||||
* @return exception types not to retry
|
||||
* @deprecated in favor of {@link #noRetryFor()}.
|
||||
*/
|
||||
@Deprecated
|
||||
@AliasFor("noRetryFor")
|
||||
Class<? extends Throwable>[] exclude() default {};
|
||||
|
||||
/**
|
||||
* Exception types that are not retryable. Defaults to empty (and, if retryFor is also
|
||||
* empty, all exceptions are retried). If retryFor is empty but excludes is not, all
|
||||
* other exceptions are retried
|
||||
* @return exception types not to retry
|
||||
* @since 2.0
|
||||
*/
|
||||
@AliasFor("exclude")
|
||||
Class<? extends Throwable>[] noRetryFor() default {};
|
||||
|
||||
/**
|
||||
* Exception types that are not recoverable; these exceptions are thrown to the caller
|
||||
* without calling any recoverer (immediately if also in {@link #noRetryFor()}).
|
||||
* Defaults to empty.
|
||||
* @return exception types not to retry
|
||||
* @since 2.0
|
||||
*/
|
||||
Class<? extends Throwable>[] notRecoverable() default {};
|
||||
|
||||
/**
|
||||
* @return the maximum number of attempts (including the first failure), defaults to 3
|
||||
*/
|
||||
|
||||
@@ -22,6 +22,8 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.core.annotation.AliasFor;
|
||||
|
||||
/**
|
||||
* Annotation for a method invocation that is retryable.
|
||||
*
|
||||
@@ -52,27 +54,63 @@ public @interface Retryable {
|
||||
String interceptor() default "";
|
||||
|
||||
/**
|
||||
* Exception types that are retryable. Synonym for includes(). Defaults to empty (and
|
||||
* Exception types that are retryable. Synonym for include(). Defaults to empty (and
|
||||
* if excludes is also empty all exceptions are retried).
|
||||
* @return exception types to retry
|
||||
* @deprecated in favor of {@link #retryFor()}
|
||||
*/
|
||||
@Deprecated
|
||||
Class<? extends Throwable>[] value() default {};
|
||||
|
||||
/**
|
||||
* Exception types that are retryable. Defaults to empty (and if excludes is also
|
||||
* empty all exceptions are retried).
|
||||
* Exception types that are retryable. Defaults to empty (and, if exclude is also
|
||||
* empty, all exceptions are retried).
|
||||
* @return exception types to retry
|
||||
* @deprecated in favor of {@link #retryFor()}.
|
||||
*/
|
||||
@AliasFor("retryFor")
|
||||
@Deprecated
|
||||
Class<? extends Throwable>[] include() default {};
|
||||
|
||||
/**
|
||||
* Exception types that are not retryable. Defaults to empty (and if includes is also
|
||||
* Exception types that are retryable. Defaults to empty (and, if noRetryFor is also
|
||||
* empty, all exceptions are retried).
|
||||
* @return exception types to retry
|
||||
* @since 2.0
|
||||
*/
|
||||
@AliasFor("include")
|
||||
Class<? extends Throwable>[] retryFor() default {};
|
||||
|
||||
/**
|
||||
* Exception types that are not retryable. Defaults to empty (and if include is also
|
||||
* empty all exceptions are retried). If includes is empty but excludes is not, all
|
||||
* not excluded exceptions are retried
|
||||
* @return exception types not to retry
|
||||
* @deprecated in favor of {@link #noRetryFor()}.
|
||||
*/
|
||||
@Deprecated
|
||||
@AliasFor("noRetryFor")
|
||||
Class<? extends Throwable>[] exclude() default {};
|
||||
|
||||
/**
|
||||
* Exception types that are not retryable. Defaults to empty (and, if retryFor is also
|
||||
* empty, all exceptions are retried). If retryFor is empty but excludes is not, all
|
||||
* other exceptions are retried
|
||||
* @return exception types not to retry
|
||||
* @since 2.0
|
||||
*/
|
||||
@AliasFor("exclude")
|
||||
Class<? extends Throwable>[] noRetryFor() default {};
|
||||
|
||||
/**
|
||||
* Exception types that are not recoverable; these exceptions are thrown to the caller
|
||||
* without calling any recoverer (immediately if also in {@link #noRetryFor()}).
|
||||
* Defaults to empty.
|
||||
* @return exception types not to retry
|
||||
* @since 2.0
|
||||
*/
|
||||
Class<? extends Throwable>[] notRecoverable() default {};
|
||||
|
||||
/**
|
||||
* A unique label for statistics reporting. If not provided the caller may choose to
|
||||
* ignore it, or provide a default.
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
|
||||
package org.springframework.retry.policy;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
@@ -70,6 +72,9 @@ public class SimpleRetryPolicy implements RetryPolicy {
|
||||
|
||||
private BinaryExceptionClassifier retryableClassifier = new BinaryExceptionClassifier(false);
|
||||
|
||||
private BinaryExceptionClassifier recoverableClassifier = new BinaryExceptionClassifier(Collections.emptyMap(),
|
||||
true, true);
|
||||
|
||||
/**
|
||||
* Create a {@link SimpleRetryPolicy} with the default number of retry attempts,
|
||||
* retrying all exceptions.
|
||||
@@ -153,6 +158,20 @@ public class SimpleRetryPolicy implements RetryPolicy {
|
||||
this.maxAttempts = maxAttempts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure throwables that should not be passed to a recoverer (if present) but
|
||||
* thrown immediately.
|
||||
* @param noRecovery the throwables.
|
||||
* @since 3.0
|
||||
*/
|
||||
public void setNotRecoverable(Class<? extends Throwable>... noRecovery) {
|
||||
Map<Class<? extends Throwable>, Boolean> map = new HashMap<>();
|
||||
for (Class<? extends Throwable> clazz : noRecovery) {
|
||||
map.put(clazz, false);
|
||||
}
|
||||
this.recoverableClassifier = new BinaryExceptionClassifier(map, true, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a supplier for the number of attempts before retries are exhausted. Includes
|
||||
* the initial attempt before the retries begin so, generally, will be {@code >= 1}.
|
||||
@@ -190,7 +209,14 @@ public class SimpleRetryPolicy implements RetryPolicy {
|
||||
@Override
|
||||
public boolean canRetry(RetryContext context) {
|
||||
Throwable t = context.getLastThrowable();
|
||||
return (t == null || retryForException(t)) && context.getRetryCount() < getMaxAttempts();
|
||||
boolean can = (t == null || retryForException(t)) && context.getRetryCount() < getMaxAttempts();
|
||||
if (!can && !this.recoverableClassifier.classify(t)) {
|
||||
context.setAttribute(RetryContext.NO_RECOVERY, true);
|
||||
}
|
||||
else {
|
||||
context.removeAttribute(RetryContext.NO_RECOVERY);
|
||||
}
|
||||
return can;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -537,20 +537,27 @@ public class RetryTemplate implements RetryOperations {
|
||||
if (state != null && !context.hasAttribute(GLOBAL_STATE)) {
|
||||
this.retryContextCache.remove(state.getKey());
|
||||
}
|
||||
boolean doRecover = !Boolean.TRUE.equals(context.getAttribute(RetryContext.NO_RECOVERY));
|
||||
if (recoveryCallback != null) {
|
||||
T recovered = recoveryCallback.recover(context);
|
||||
context.setAttribute(RetryContext.RECOVERED, true);
|
||||
return recovered;
|
||||
if (doRecover) {
|
||||
T recovered = recoveryCallback.recover(context);
|
||||
context.setAttribute(RetryContext.RECOVERED, true);
|
||||
return recovered;
|
||||
}
|
||||
else {
|
||||
logger.debug("Retry exhausted and recovery disabled for this throwable");
|
||||
}
|
||||
}
|
||||
if (state != null) {
|
||||
this.logger.debug("Retry exhausted after last attempt with no recovery path.");
|
||||
rethrow(context, "Retry exhausted after last attempt with no recovery path");
|
||||
rethrow(context, "Retry exhausted after last attempt with no recovery path",
|
||||
this.throwLastExceptionOnExhausted || !doRecover);
|
||||
}
|
||||
throw wrapIfNecessary(context.getLastThrowable());
|
||||
}
|
||||
|
||||
protected <E extends Throwable> void rethrow(RetryContext context, String message) throws E {
|
||||
if (this.throwLastExceptionOnExhausted) {
|
||||
protected <E extends Throwable> void rethrow(RetryContext context, String message, boolean wrap) throws E {
|
||||
if (wrap) {
|
||||
@SuppressWarnings("unchecked")
|
||||
E rethrow = (E) context.getLastThrowable();
|
||||
throw rethrow;
|
||||
|
||||
@@ -48,7 +48,6 @@ import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
||||
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
|
||||
import static org.assertj.core.api.Assertions.setMaxStackTraceElementsDisplayed;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -121,7 +120,13 @@ public class EnableRetryTests {
|
||||
RecoverableService service = context.getBean(RecoverableService.class);
|
||||
service.service();
|
||||
assertThat(service.getCount()).isEqualTo(3);
|
||||
assertNotNull(service.getCause());
|
||||
assertThat(service.getCause()).isExactlyInstanceOf(RuntimeException.class);
|
||||
assertThatIllegalArgumentException().isThrownBy(() -> service.service());
|
||||
assertThat(service.getCount()).isEqualTo(6);
|
||||
assertThat(service.getCause()).isExactlyInstanceOf(RuntimeException.class);
|
||||
assertThatIllegalStateException().isThrownBy(() -> service.service());
|
||||
assertThat(service.getCount()).isEqualTo(7);
|
||||
assertThat(service.getCause()).isExactlyInstanceOf(RuntimeException.class);
|
||||
context.close();
|
||||
}
|
||||
|
||||
@@ -582,10 +587,18 @@ public class EnableRetryTests {
|
||||
|
||||
boolean otherAdviceCalled;
|
||||
|
||||
@Retryable(RuntimeException.class)
|
||||
@Retryable(retryFor = RuntimeException.class, noRetryFor = IllegalStateException.class,
|
||||
notRecoverable = { IllegalArgumentException.class, IllegalStateException.class })
|
||||
public void service() {
|
||||
this.count++;
|
||||
throw new RuntimeException("Planned");
|
||||
if (this.count++ >= 3 && count < 7) {
|
||||
throw new IllegalArgumentException("Planned");
|
||||
}
|
||||
else if (count > 6) {
|
||||
throw new IllegalStateException("Planned");
|
||||
}
|
||||
else {
|
||||
throw new RuntimeException("Planned");
|
||||
}
|
||||
}
|
||||
|
||||
@Recover
|
||||
@@ -632,7 +645,7 @@ public class EnableRetryTests {
|
||||
|
||||
private int count = 0;
|
||||
|
||||
@Retryable(include = RuntimeException.class, exclude = IllegalStateException.class)
|
||||
@Retryable(retryFor = RuntimeException.class, noRetryFor = IllegalStateException.class)
|
||||
public void service() {
|
||||
if (this.count++ < 2) {
|
||||
throw new IllegalStateException("Planned");
|
||||
@@ -651,7 +664,7 @@ public class EnableRetryTests {
|
||||
|
||||
private RuntimeException exceptionToThrow;
|
||||
|
||||
@Retryable(exclude = IllegalStateException.class)
|
||||
@Retryable(noRetryFor = IllegalStateException.class)
|
||||
public void service() {
|
||||
if (this.count++ < 2) {
|
||||
throw this.exceptionToThrow;
|
||||
|
||||
@@ -261,7 +261,7 @@ public class RetryTemplateTests {
|
||||
});
|
||||
assertThatExceptionOfType(TerminatedRetryException.class).isThrownBy(() -> retryTemplate.execute(context -> {
|
||||
throw new RuntimeException("Realllly bad!");
|
||||
})).withCauseInstanceOf(RuntimeException.class).withMessageContaining("Planned");
|
||||
})).withCauseInstanceOf(RuntimeException.class).withStackTraceContaining("Planned");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -170,7 +170,7 @@ public class StatefulRecoveryRetryTests {
|
||||
// inconsistent has codes relies on the cache having been used for this
|
||||
// item already...
|
||||
assertThatExceptionOfType(RetryException.class).isThrownBy(() -> this.retryTemplate.execute(callback, state))
|
||||
.withMessageContaining("inconsistent");
|
||||
.withStackTraceContaining("inconsistent");
|
||||
|
||||
RetryContext context = this.retryTemplate.open(retryPolicy, state);
|
||||
// True after exhausted - the history is reset...
|
||||
@@ -195,7 +195,7 @@ public class StatefulRecoveryRetryTests {
|
||||
|
||||
assertThatExceptionOfType(RetryException.class)
|
||||
.isThrownBy(() -> this.retryTemplate.execute(callback, new DefaultRetryState("bar")))
|
||||
.withMessageContaining("capacity");
|
||||
.withStackTraceContaining("capacity");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user