diff --git a/README.md b/README.md index 0d91a7d..e79aa02 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,34 @@ class Service { } ``` +Version 1.2 introduces the ability to use expressions for certain properties: + +```java + +@Retryable(exceptionExpression="#{message.contains('this can be retried')}") +public void service1() { + ... +} + +@Retryable(exceptionExpression="#{message.contains('this can be retried')}") +public void service2() { + ... +} + +@Retryable(exceptionExpression="#{@exceptionChecker.shouldRetry(#root)}", + maxAttemptsExpression = "#{@integerFiveBean}", + backoff = @Backoff(delayExpression = "#{1}", maxDelayExpression = "#{5}", multiplierExpression = "#{1.1}")) +public void service3() { + ... +} +``` + +These use the familier Spring SpEL expression syntax (`#{...}`). + +- `exceptionExpression` is evaluated against the thrown exception as the `#root` object. +- `maxAttemptsExpression` and the `@BackOff` expression attributes are evaluated once, during initialization; there is no root object for the evaluation but they can reference other beans in the context. + + ### XML Configuration Here is an example of declarative iteration using Spring AOP to repeat a service call to a method called `remoteCall` (for more detail on how to configure AOP interceptors see the Spring User Guide): diff --git a/src/main/java/org/springframework/retry/annotation/AnnotationAwareRetryOperationsInterceptor.java b/src/main/java/org/springframework/retry/annotation/AnnotationAwareRetryOperationsInterceptor.java index 3db6ecb..daec0f3 100644 --- a/src/main/java/org/springframework/retry/annotation/AnnotationAwareRetryOperationsInterceptor.java +++ b/src/main/java/org/springframework/retry/annotation/AnnotationAwareRetryOperationsInterceptor.java @@ -26,12 +26,17 @@ import java.util.concurrent.atomic.AtomicBoolean; import org.aopalliance.intercept.MethodInterceptor; import org.aopalliance.intercept.MethodInvocation; + import org.springframework.aop.IntroductionInterceptor; import org.springframework.beans.BeansException; import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.context.expression.BeanFactoryResolver; import org.springframework.core.annotation.AnnotationAwareOrderComparator; import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.expression.common.TemplateParserContext; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; import org.springframework.retry.RetryListener; import org.springframework.retry.RetryPolicy; import org.springframework.retry.backoff.BackOffPolicy; @@ -47,6 +52,7 @@ import org.springframework.retry.interceptor.MethodInvocationRecoverer; import org.springframework.retry.interceptor.NewMethodArgumentsIdentifier; import org.springframework.retry.interceptor.RetryInterceptorBuilder; import org.springframework.retry.policy.CircuitBreakerRetryPolicy; +import org.springframework.retry.policy.ExpressionRetryPolicy; import org.springframework.retry.policy.MapRetryContextCache; import org.springframework.retry.policy.RetryContextCache; import org.springframework.retry.policy.SimpleRetryPolicy; @@ -67,6 +73,12 @@ import org.springframework.util.StringUtils; */ public class AnnotationAwareRetryOperationsInterceptor implements IntroductionInterceptor, BeanFactoryAware { + private static final TemplateParserContext PARSER_CONTEXT = new TemplateParserContext(); + + private static final SpelExpressionParser PARSER = new SpelExpressionParser(); + + private final StandardEvaluationContext evaluationContext = new StandardEvaluationContext(); + private final Map delegates = new HashMap(); private RetryContextCache retryContextCache = new MapRetryContextCache(); @@ -113,7 +125,7 @@ public class AnnotationAwareRetryOperationsInterceptor implements IntroductionIn /** * Retry listeners to apply to all operations. - * @param listeners the listeners + * @param listeners the listeners */ public void setListeners(Collection listeners) { ArrayList retryListeners = new ArrayList(listeners); @@ -124,6 +136,7 @@ public class AnnotationAwareRetryOperationsInterceptor implements IntroductionIn @Override public void setBeanFactory(BeanFactory beanFactory) throws BeansException { this.beanFactory = beanFactory; + this.evaluationContext.setBeanResolver(new BeanFactoryResolver(beanFactory)); } @Override @@ -263,6 +276,8 @@ public class AnnotationAwareRetryOperationsInterceptor implements IntroductionIn Map attrs = AnnotationUtils.getAnnotationAttributes(retryable); @SuppressWarnings("unchecked") Class[] includes = (Class[]) attrs.get("value"); + String exceptionExpression = (String) attrs.get("exceptionExpression"); + boolean hasExpression = StringUtils.hasText(exceptionExpression); if (includes.length == 0) { @SuppressWarnings("unchecked") Class[] value = (Class[]) attrs.get("include"); @@ -270,9 +285,17 @@ public class AnnotationAwareRetryOperationsInterceptor implements IntroductionIn } @SuppressWarnings("unchecked") Class[] excludes = (Class[]) attrs.get("exclude"); + Integer maxAttempts = (Integer) attrs.get("maxAttempts"); + String maxAttemptsExpression = (String) attrs.get("maxAttemptsExpression"); + if (StringUtils.hasText(maxAttemptsExpression)) { + maxAttempts = PARSER.parseExpression(maxAttemptsExpression, PARSER_CONTEXT).getValue(this.evaluationContext, + Integer.class); + } if (includes.length == 0 && excludes.length == 0) { - SimpleRetryPolicy simple = new SimpleRetryPolicy(); - simple.setMaxAttempts((Integer) attrs.get("maxAttempts")); + SimpleRetryPolicy simple = hasExpression ? new ExpressionRetryPolicy(exceptionExpression) + .withBeanFactory(this.beanFactory) + : new SimpleRetryPolicy(); + simple.setMaxAttempts(maxAttempts); return simple; } Map, Boolean> policyMap = new HashMap, Boolean>(); @@ -282,19 +305,38 @@ public class AnnotationAwareRetryOperationsInterceptor implements IntroductionIn for (Class type : excludes) { policyMap.put(type, false); } - return new SimpleRetryPolicy((Integer) attrs.get("maxAttempts"), policyMap, true); + if (hasExpression) { + return new ExpressionRetryPolicy(maxAttempts, policyMap, true, exceptionExpression) + .withBeanFactory(this.beanFactory); + } + else { + return new SimpleRetryPolicy(maxAttempts, policyMap, true); + } } private BackOffPolicy getBackoffPolicy(Backoff backoff) { long min = backoff.delay() == 0 ? backoff.value() : backoff.delay(); + if (StringUtils.hasText(backoff.delayExpression())) { + min = PARSER.parseExpression(backoff.delayExpression(), PARSER_CONTEXT).getValue(this.evaluationContext, + Long.class); + } long max = backoff.maxDelay(); - if (backoff.multiplier() > 0) { + if (StringUtils.hasText(backoff.maxDelayExpression())) { + max = PARSER.parseExpression(backoff.maxDelayExpression(), PARSER_CONTEXT).getValue(this.evaluationContext, + Long.class); + } + double multiplier = backoff.multiplier(); + if (StringUtils.hasText(backoff.multiplierExpression())) { + multiplier = PARSER.parseExpression(backoff.multiplierExpression(), PARSER_CONTEXT) + .getValue(this.evaluationContext, Double.class); + } + if (multiplier > 0) { ExponentialBackOffPolicy policy = new ExponentialBackOffPolicy(); if (backoff.random()) { policy = new ExponentialRandomBackOffPolicy(); } policy.setInitialInterval(min); - policy.setMultiplier(backoff.multiplier()); + policy.setMultiplier(multiplier); policy.setMaxInterval(max > min ? max : ExponentialBackOffPolicy.DEFAULT_MAX_INTERVAL); if (this.sleeper != null) { policy.setSleeper(this.sleeper); diff --git a/src/main/java/org/springframework/retry/annotation/Backoff.java b/src/main/java/org/springframework/retry/annotation/Backoff.java index 1e2f69a..f93055a 100644 --- a/src/main/java/org/springframework/retry/annotation/Backoff.java +++ b/src/main/java/org/springframework/retry/annotation/Backoff.java @@ -27,7 +27,7 @@ import org.springframework.retry.backoff.BackOffPolicy; /** * Collects metadata for a {@link BackOffPolicy}. Features: - * + * *
    *
  • With no explicit settings the default is a fixed delay of 1000ms
  • *
  • Only the {@link #delay()} set: the backoff is a fixed delay with that value
  • @@ -38,9 +38,10 @@ import org.springframework.retry.backoff.BackOffPolicy; *
  • If, in addition, the {@link #random()} flag is set then the multiplier is chosen * for each delay from a uniform distribution in [1, multiplier-1]
  • *
- * + * * @author Dave Syer - * @since 2.0 + * @author Gary Russell + * @since 1.1 * */ @Target(ElementType.TYPE) @@ -51,7 +52,7 @@ public @interface Backoff { /** * Synonym for {@link #delay()}. - * + * * @return the delay in milliseconds (default 1000) */ long value() default 1000; @@ -66,24 +67,53 @@ public @interface Backoff { /** * The maximimum wait (in milliseconds) between retries. If less than the * {@link #delay()} then ignored. - * + * * @return the maximum delay between retries (default 0 = ignored) */ long maxDelay() default 0; /** * If positive, then used as a multiplier for generating the next delay for backoff. - * + * * @return a multiplier to use to calculate the next backoff delay (default 0 = * ignored) */ double multiplier() default 0; + /** + * An expression evaluating to the canonical backoff period. Used as an initial value + * in the exponential case, and as a minimum value in the uniform case. + * Overrides {@link #delay()}. + * @return the initial or canonical backoff period in milliseconds. + * @since 1.2 + */ + String delayExpression() default ""; + + /** + * An expression evaluating to the maximimum wait (in milliseconds) between retries. + * If less than the {@link #delay()} then ignored. + * Overrides {@link #maxDelay()} + * + * @return the maximum delay between retries (default 0 = ignored) + * @since 1.2 + */ + String maxDelayExpression() default ""; + + /** + * Evaluates to a vaule used as a multiplier for generating the next delay for backoff. + * Overrides {@link #multiplier()}. + * + * @return a multiplier expression to use to calculate the next backoff delay (default 0 = + * ignored) + * @since 1.2 + */ + String multiplierExpression() default ""; + /** * In the exponential case ({@link #multiplier()} > 0) set this to true to have the * backoff delays randomized, so that the maximum delay is multiplier times the * previous delay and the distribution is uniform between the two values. - * + * * @return the flag to signal randomization is required (default false) */ boolean random() default false; diff --git a/src/main/java/org/springframework/retry/annotation/Retryable.java b/src/main/java/org/springframework/retry/annotation/Retryable.java index de04526..8275320 100644 --- a/src/main/java/org/springframework/retry/annotation/Retryable.java +++ b/src/main/java/org/springframework/retry/annotation/Retryable.java @@ -27,6 +27,7 @@ import java.lang.annotation.Target; * * @author Dave Syer * @author Artem Bilan + * @author Gary Russell * @since 1.1 * */ @@ -84,6 +85,13 @@ public @interface Retryable { */ int maxAttempts() default 3; + /** + * @return an expression evaluated to the maximum number of attempts (including the first failure), defaults to 3 + * Overrides {@link #maxAttempts()}. + * @since 1.2 + */ + String maxAttemptsExpression() default ""; + /** * Specify the backoff properties for retrying this operation. The default is no * backoff, but it can be a good idea to pause between attempts (even at the cost of @@ -92,4 +100,22 @@ public @interface Retryable { */ Backoff backoff() default @Backoff(); + /** + * Specify an expression to be evaluated after the {@code SimpleRetryPolicy.canRetry()} + * returns true - can be used to conditionally suppress the retry. Only invoked after + * an exception is thrown. The root object for the evaluation is the last {@code Throwable}. + * Other beans in the context can be referenced. + * For example: + *
+	 *  {@code "message.contains('you can retry this')"}.
+	 * 
+ * and + *
+	 *  {@code "@someBean.shouldRetry(#root)"}.
+	 * 
+ * @return the expression. + * @since 1.2 + */ + String exceptionExpression() default ""; + } diff --git a/src/main/java/org/springframework/retry/policy/ExpressionRetryPolicy.java b/src/main/java/org/springframework/retry/policy/ExpressionRetryPolicy.java new file mode 100644 index 0000000..88e8843 --- /dev/null +++ b/src/main/java/org/springframework/retry/policy/ExpressionRetryPolicy.java @@ -0,0 +1,116 @@ +/* + * Copyright 2015 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 + * + * http://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.retry.policy; + +import java.util.Map; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.context.expression.BeanFactoryResolver; +import org.springframework.expression.Expression; +import org.springframework.expression.common.TemplateParserContext; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.retry.RetryContext; +import org.springframework.util.Assert; + +/** + * Subclass of {@link SimpleRetryPolicy} that delegates to super.canRetry() and, + * if true, further evaluates an expression against the last thrown exception. + * + * @author Gary Russell + * @since 1.2 + * + */ +@SuppressWarnings("serial") +public class ExpressionRetryPolicy extends SimpleRetryPolicy implements BeanFactoryAware { + + private static final TemplateParserContext PARSER_CONTEXT = new TemplateParserContext(); + + private final Expression expression; + + private final StandardEvaluationContext evaluationContext = new StandardEvaluationContext(); + + /** + * Construct an instance with the provided {@link Expression}. + * @param expression the expression + */ + public ExpressionRetryPolicy(Expression expression) { + Assert.notNull(expression, "'expression' cannot be null"); + this.expression = expression; + } + + /** + * Construct an instance with the provided expression. + * @param expressionString the expression. + */ + public ExpressionRetryPolicy(String expressionString) { + Assert.notNull(expressionString, "'expressionString' cannot be null"); + this.expression = new SpelExpressionParser().parseExpression(expressionString, PARSER_CONTEXT); + } + + /** + * Construct an instance with the provided {@link Expression}. + * @param maxAttempts the max attempts + * @param retryableExceptions the exceptions + * @param traverseCauses true to examine causes + * @param expression the expression + */ + public ExpressionRetryPolicy(int maxAttempts, Map, Boolean> retryableExceptions, + boolean traverseCauses, Expression expression) { + super(maxAttempts, retryableExceptions, traverseCauses); + Assert.notNull(expression, "'expression' cannot be null"); + this.expression = expression; + } + + /** + * Construct an instance with the provided expression. + * @param maxAttempts the max attempts + * @param retryableExceptions the exceptions + * @param traverseCauses true to examine causes + * @param expressionString the expression. + */ + public ExpressionRetryPolicy(int maxAttempts, Map, Boolean> retryableExceptions, + boolean traverseCauses, String expressionString) { + super(maxAttempts, retryableExceptions, traverseCauses); + Assert.notNull(expressionString, "'expressionString' cannot be null"); + this.expression = new SpelExpressionParser().parseExpression(expressionString, PARSER_CONTEXT); + } + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + this.evaluationContext.setBeanResolver(new BeanFactoryResolver(beanFactory)); + } + + public ExpressionRetryPolicy withBeanFactory(BeanFactory beanFactory) { + setBeanFactory(beanFactory); + return this; + } + + @Override + public boolean canRetry(RetryContext context) { + Throwable lastThrowable = context.getLastThrowable(); + if (lastThrowable == null) { + return super.canRetry(context); + } + else { + return super.canRetry(context) + && this.expression.getValue(this.evaluationContext, lastThrowable, Boolean.class); + } + } + +} diff --git a/src/test/java/org/springframework/retry/annotation/EnableRetryTests.java b/src/test/java/org/springframework/retry/annotation/EnableRetryTests.java index 122cde2..bc9184b 100644 --- a/src/test/java/org/springframework/retry/annotation/EnableRetryTests.java +++ b/src/test/java/org/springframework/retry/annotation/EnableRetryTests.java @@ -22,15 +22,22 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; +import java.lang.reflect.Method; +import java.util.Map; + import org.aopalliance.intercept.MethodInterceptor; import org.junit.Test; import org.springframework.aop.support.AopUtils; +import org.springframework.beans.DirectFieldAccessor; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.retry.backoff.ExponentialBackOffPolicy; import org.springframework.retry.backoff.Sleeper; import org.springframework.retry.interceptor.RetryInterceptorBuilder; +import org.springframework.retry.policy.SimpleRetryPolicy; +import org.springframework.retry.support.RetryTemplate; /** * @author Dave Syer @@ -156,6 +163,43 @@ public class EnableRetryTests { context.close(); } + @Test + public void testExpression() throws Exception { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(TestConfiguration.class); + ExpressionService service = context.getBean(ExpressionService.class); + service.service1(); + assertEquals(3, service.getCount()); + try { + service.service2(); + fail("expected exception"); + } + catch (RuntimeException e) { + assertEquals("this cannot be retried", e.getMessage()); + } + assertEquals(4, service.getCount()); + service.service3(); + assertEquals(9, service.getCount()); + RetryConfiguration config = context.getBean(RetryConfiguration.class); + AnnotationAwareRetryOperationsInterceptor advice = + (AnnotationAwareRetryOperationsInterceptor) new DirectFieldAccessor(config).getPropertyValue("advice"); + @SuppressWarnings("unchecked") + Map delegates = (Map) new DirectFieldAccessor(advice) + .getPropertyValue("delegates"); + MethodInterceptor interceptor = delegates + .get(ExpressionService.class.getDeclaredMethod("service3")); + RetryTemplate template = (RetryTemplate) new DirectFieldAccessor(interceptor) + .getPropertyValue("retryOperations"); + DirectFieldAccessor templateAccessor = new DirectFieldAccessor(template); + ExponentialBackOffPolicy backOff = (ExponentialBackOffPolicy) templateAccessor + .getPropertyValue("backOffPolicy"); + assertEquals(1, backOff.getInitialInterval()); + assertEquals(5, backOff.getMaxInterval()); + assertEquals(1.1, backOff.getMultiplier(), 0.1); + SimpleRetryPolicy retryPolicy = (SimpleRetryPolicy) templateAccessor.getPropertyValue("retryPolicy"); + assertEquals(5, retryPolicy.getMaxAttempts()); + context.close(); + } + @Configuration @EnableRetry(proxyTargetClass = true) protected static class TestProxyConfiguration { @@ -223,6 +267,21 @@ public class EnableRetryTests { return new InterceptableService(); } + @Bean + public ExpressionService expressionService() { + return new ExpressionService(); + } + + @Bean + public ExceptionChecker exceptionChecker() { + return new ExceptionChecker(); + } + + @Bean + public Integer integerFiveBean() { + return Integer.valueOf(5); + } + @Bean public Foo foo() { return new Foo(); @@ -371,6 +430,46 @@ public class EnableRetryTests { } + private static class ExpressionService { + + private int count = 0; + + @Retryable(exceptionExpression="#{message.contains('this can be retried')}") + public void service1() { + if (count++ < 2) { + throw new RuntimeException("this can be retried"); + } + } + + @Retryable(exceptionExpression="#{message.contains('this can be retried')}") + public void service2() { + count++; + throw new RuntimeException("this cannot be retried"); + } + + @Retryable(exceptionExpression="#{@exceptionChecker.shouldRetry(#root)}", + maxAttemptsExpression = "#{@integerFiveBean}", + backoff = @Backoff(delayExpression = "#{1}", maxDelayExpression = "#{5}", multiplierExpression = "#{1.1}")) + public void service3() { + if (count++ < 8) { + throw new RuntimeException(); + } + } + + public int getCount() { + return count; + } + + } + + public static class ExceptionChecker { + + public boolean shouldRetry(Throwable t) { + return true; + } + + } + private static class Foo { }