Spring Framework 7 adds two resilience features directly to the core framework: retry and concurrency throttling. In this blog post, we will explore both features.
Retry ¶
Spring 7's retry support includes both declarative and programmatic options. The declarative approach uses the @Retryable annotation, while the programmatic approach uses the RetryTemplate class. To enable annotation-based retry, you need to add @EnableResilientMethods to your configuration.
@SpringBootApplication
@EnableResilientMethods
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
Declarative ¶
You can then annotate any method with @Retryable to specify the retry behavior. For example:
@Retryable
public void someMethod() {
// method implementation
}
With a "naked" @Retryable, the method will be:
- retried up to three times
- retried for any exception
- delayed by 1000 milliseconds between attempts
- configured with no multiplier or jitter
- configured with no timeout
- configured with no custom predicate
- configured with no exclusions
You can configure all of those aspects through the annotation's attributes.
@Retryable(
includes = TransientFailureException.class,
maxRetries = 4,
delayString = "PT0.2S",
multiplier = 2.0,
jitter = 500,
timeoutString = "PT10S"
)
public void someMethod() {
// ...
}
This retries only TransientFailureException, with up to four retries, starting with a 200 ms delay that doubles each time, plus a random jitter of up to 500 ms and a total timeout of 10 seconds for all attempts.
The attributes of @Retryable allow you to fine-tune the retry behavior:
includes: Specifies which exceptions should trigger a retry.excludes: Specifies which exceptions should not trigger a retry.predicate: Allows you to define a custom predicate to determine whether a retry should occur.maxRetries: Sets the maximum number of retry attempts.maxRetriesString: Configures the maximum number of retry attempts as a String, supporting placeholders and SpEL expressions.timeout: Configures the total timeout for all retry attempts, specified in milliseconds by default but overridable withtimeUnit.timeoutString: Configures the total timeout for all retry attempts as a String.delay: Configures the delay between retry attempts, specified in milliseconds by default but overridable withtimeUnit.delayString: Configures the delay between retry attempts as a String.jitter: Adds random jitter to the delay between retry attempts, specified in milliseconds by default but overridable withtimeUnit.jitterString: Configures the jitter as a String.multiplier: Applies a multiplier to the delay for each subsequent retry attempt. Starting with the initial delay, each retry has its delay multiplied by this value.multiplierString: Configures the multiplier as a String.maxDelay: Sets the maximum delay for any retry attempt, limiting how much the delay can increase due to jitter and multiplier. It is specified in milliseconds by default but can be overridden withtimeUnit.maxDelayString: Configures the maximum delay as a String.timeUnit: Specifies the time unit fordelay,delayString,jitter,jitterString,maxDelay, andmaxDelayString. The default is milliseconds.
The predicate attribute allows you to define a custom retry condition that goes beyond exception types. For example, you might want to retry only when an exception has a certain message or when a method returns a specific value. You can implement the MethodRetryPredicate interface to create your own predicate logic.
Here is an example of a custom MethodRetryPredicate that retries only when the exception is an instance of UpstreamFailureException and the status code is 429 or 5xx:
public final class UpstreamStatusRetryPredicate implements MethodRetryPredicate {
@Override
public boolean shouldRetry(Method method, Throwable throwable) {
return throwable instanceof UpstreamFailureException upstreamFailure
&& (upstreamFailure.getStatusCode() == 429 || upstreamFailure.getStatusCode() >= 500);
}
}
Then you can use this predicate in your @Retryable annotation:
@Retryable(predicate = UpstreamStatusRetryPredicate.class)
public void someMethod() {
// ...
}
Programmatic ¶
If you prefer a programmatic approach, or if you need a more fine-grained retry boundary, you can use the RetryTemplate class. You can configure a RetryTemplate bean with the desired RetryPolicy, inject it into your service, and invoke it with a lambda or method reference.
@Bean
RetryTemplate retryTemplate() {
RetryPolicy retryPolicy = RetryPolicy.builder()
.includes(TransientFailureException.class)
.maxRetries(4)
.delay(Duration.ofMillis(200))
.build();
return new RetryTemplate(retryPolicy);
}
Then you can use the retryTemplate to execute a block of code with the defined retry behavior:
retryTemplate.execute(() -> {
// code that may throw an exception and should be retried
});
If you have Supplier or Runnable instances, you can also use the invoke method to run them with retry semantics.
Supplier<String> retryableSupplier = () -> {
// code that may throw an exception and should be retried, returning a String result
};
retryTemplate.invoke(retryableSupplier);
The RetryPolicy supports the exact same configuration options as @Retryable. The only difference is that there are no String variants of the attributes, since you can use Java code to compute any values you need. You also cannot set the time unit because you can use Duration for all time-based values.
Predicate ¶
Like the annotation-based approach, the programmatic RetryTemplate also supports predicates. You can define a RetryPolicy with a custom predicate to determine whether a retry should occur based on the exception or other conditions.
RetryPolicy retryPolicy = RetryPolicy.builder()
.includes(UpstreamFailureException.class)
.predicate(throwable -> throwable instanceof UpstreamFailureException upstreamFailure
&& (upstreamFailure.getStatusCode() == 429 || upstreamFailure.getStatusCode() >= 500))
.maxRetries(4)
.delay(Duration.ofMillis(200))
.build();
Listener ¶
Listeners allow you to hook into retry lifecycle events, such as before a retry attempt, after a successful attempt, or after a failed attempt. You can implement the RetryListener interface and register it with the RetryTemplate to receive callbacks for these events.
RetryListener myListener = new RetryListener() {
@Override
public void onRetryableExecution(RetryPolicy retryPolicy, Retryable<?> retryable, RetryState retryState) {
// This method is called after every attempt, including the initial invocation.
// You can check if the attempt was successful or not using retryState.isSuccessful()
// and get the last exception if it was not successful using retryState.getLastException()
}
@Override
public void beforeRetry(RetryPolicy retryPolicy, Retryable<?> retryable, RetryState retryState) {
// This method is called before every retry attempt.
}
@Override
public void onRetrySuccess(RetryPolicy retryPolicy, Retryable<?> retryable, @Nullable Object result) {
// This method is called after a successful attempt.
}
@Override
public void onRetryFailure(RetryPolicy retryPolicy, Retryable<?> retryable, Throwable throwable) {
// This method is called after every failed retry attempt.
}
@Override
public void onRetryPolicyExhaustion(RetryPolicy retryPolicy, Retryable<?> retryable, RetryException exception) {
// This method is called if the RetryPolicy is exhausted, meaning that the maximum number of retries has been reached without a successful attempt.
}
@Override
public void onRetryPolicyInterruption(RetryPolicy retryPolicy, Retryable<?> retryable, RetryException exception) {
// This method is called if the RetryPolicy is interrupted between retry attempts. This can happen if the thread executing the retryable code is interrupted, for example.
}
@Override
public void onRetryPolicyTimeout(RetryPolicy retryPolicy, Retryable<?> retryable, RetryException exception) {
// This method is called if the configured timeout for a RetryPolicy is exceeded.
}
};
RetryTemplate retryTemplate = new RetryTemplate(retryPolicy);
retryTemplate.setRetryListener(myListener);
Concurrency throttling ¶
The other feature in Spring 7's resilience package is concurrency throttling. It allows you to limit the number of concurrent calls to a method, which can be useful for protecting downstream resources or preventing overload. You can use the @ConcurrencyLimit annotation to specify a concurrency limit and throttle policy for a method.
You must also add @EnableResilientMethods to your configuration to enable the annotation-based concurrency throttling.
@ConcurrencyLimit(2)
public void someMethod() {
// ...
}
In this example, the @ConcurrencyLimit(2) annotation limits the number of concurrent calls to someMethod to 2. If more than 2 calls are made at the same time, the additional calls will be blocked until a slot becomes available.
You can specify the throttle policy using the policy attribute of the annotation. The default policy is BLOCK, which means that excess calls will wait until a slot becomes available. Alternatively, you can use the REJECT policy to have excess calls fail immediately with an InvocationRejectedException.
@ConcurrencyLimit(limit = 2, policy = ConcurrencyLimit.ThrottlePolicy.REJECT)
public void someMethod() {
// ...
}
With this setup, when three concurrent calls are made to someMethod, the first two will execute successfully, while the third call will be rejected immediately with an InvocationRejectedException.
Like the retry annotation, you can also specify the concurrency limit as a String, which allows for placeholders and SpEL expressions:
@ConcurrencyLimit(limitString = "${app.concurrencyLimit:2}")
public void someMethod() {
// ...
}
This allows you to configure the concurrency limit externally through properties, with a default value of 2 if the property is not set.
Programmatic ¶
There is no one-to-one programmatic equivalent of @ConcurrencyLimit, but you can, for example, use SimpleAsyncTaskExecutor to manage concurrency in a more manual way. You can configure the task executor with a concurrency limit and then submit tasks to it, which will execute according to the defined concurrency constraints.
SimpleAsyncTaskExecutor taskExecutor = new SimpleAsyncTaskExecutor();
taskExecutor.setConcurrencyLimit(2);
taskExecutor.execute(() -> {
// ... code to execute with concurrency limit
});
Wrapping up ¶
Spring 7 added two common resilience patterns directly to the core framework: retry and concurrency throttling. The retry support includes both declarative and programmatic options, with flexible configuration and predicate support. Concurrency throttling allows you to limit the number of concurrent calls to a method, with options for blocking or rejecting excess calls. These features are focused and lightweight, making them a good fit for many applications without requiring additional dependencies.