Skip to content

Commit

Permalink
Spring retry mechanism
Browse files Browse the repository at this point in the history
  • Loading branch information
ttcollins committed Mar 22, 2024
1 parent e07810e commit f33e6c4
Show file tree
Hide file tree
Showing 10 changed files with 365 additions and 0 deletions.
6 changes: 6 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<packaging>pom</packaging>
<modules>
<module>SpringBootTesting</module>
<module>springretry</module>
</modules>

<properties>
Expand Down Expand Up @@ -53,6 +54,11 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
</dependencies>

<build>
Expand Down
2 changes: 2 additions & 0 deletions springretry/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
### Resources used in this project:
- [Guide to Spring Retry](https://www.baeldung.com/spring-retry)
61 changes: 61 additions & 0 deletions springretry/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.savea</groupId>
<artifactId>spring-boot-utils</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>

<artifactId>springretry</artifactId>

<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
<version>2.0.3</version>
</dependency>

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aspects</artifactId>
<version>6.0.11</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.savea.springretry;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SpringRetryApplication {

public static void main(String[] args) {
SpringApplication.run(SpringRetryApplication.class, args);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package org.savea.springretry.configs;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
import org.springframework.retry.annotation.EnableRetry;
import org.springframework.retry.backoff.FixedBackOffPolicy;
import org.springframework.retry.policy.SimpleRetryPolicy;
import org.springframework.retry.support.RetryTemplate;

@Configuration
@EnableRetry
@PropertySource("classpath:retryConfig.properties")
@ComponentScan(basePackages = "org.savea.springretry")
public class AppConfig {

/**
* The RetryPolicy determines when an operation should be retried.
*<p/>
* A SimpleRetryPolicy is used to retry a fixed number of times.
* On the other hand, the BackOffPolicy is used to control backoff between retry attempts.
*<p/>
* Finally, a FixedBackOffPolicy pauses for a fixed period of time before continuing.
* @return RetryTemplate
*/
@Bean
public RetryTemplate retryTemplate() {
RetryTemplate retryTemplate = new RetryTemplate();

FixedBackOffPolicy fixedBackOffPolicy = new FixedBackOffPolicy();
fixedBackOffPolicy.setBackOffPeriod(2000l);
retryTemplate.setBackOffPolicy(fixedBackOffPolicy);

SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy();
retryPolicy.setMaxAttempts(2);
retryTemplate.setRetryPolicy(retryPolicy);

// Registering our listener (DefaultListenerSupport) to our RetryTemplate bean
retryTemplate.registerListener(new DefaultListenerSupport());
return retryTemplate;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package org.savea.springretry.configs;

import lombok.extern.slf4j.Slf4j;
import org.springframework.retry.RetryCallback;
import org.springframework.retry.RetryContext;
import org.springframework.retry.listener.MethodInvocationRetryListenerSupport;

/**
* Listeners provide additional callbacks upon retries.
* And we can use these for various cross-cutting concerns across different retries.
* <p/>
* The callbacks are provided in a RetryListener interface.
* <p/>
* The open and close callbacks come before and after the entire retry,
* while onError applies to the individual RetryCallback calls.
*/
@Slf4j
public class DefaultListenerSupport extends MethodInvocationRetryListenerSupport {

@Override
public <T, E extends Throwable> void close(RetryContext context, RetryCallback<T, E> callback, Throwable throwable) {
log.info("onClose");
super.close(context, callback, throwable);
}

@Override
public <T, E extends Throwable> void onError(RetryContext context, RetryCallback<T, E> callback, Throwable throwable) {
log.info("onError");
super.onError(context, callback, throwable);
}

@Override
public <T, E extends Throwable> boolean open(RetryContext context, RetryCallback<T, E> callback) {
log.info("onOpen");
return super.open(context, callback);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package org.savea.springretry.services;

import java.sql.SQLException;

import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;


public interface MyService {

/**
* @Retryable Without Recovery
* <p/>
* Since we have not specified any exceptions here, retry will be attempted for all the exceptions.
* Also, once the max attempts are reached and there is still an exception, ExhaustedRetryException will be thrown.
*<p/>
* Per @Retryable's default behavior, the retry may happen up to three times, with a delay of one second between retries.
*/
@Retryable
void retryService();

/**
* @Retryable and @Recover
* <p/>
* Here, the retry is attempted when an SQLException is thrown.
* The @Recover annotation defines a separate recovery method when a @Retryable method fails with a specified exception.
* <p/>
* Consequently, if the retryServiceWithRecovery method keeps throwing an SqlException after three attempts,
* the recover() method will be called.
* <p/>
* The recovery handler should have the first parameter of type Throwable (optional) and the same return type.
* The following arguments are populated from the argument list of the failed method in the same order.
* @param sql, the SQL query
* @throws SQLException if the SQL query is empty
*/
@Retryable(retryFor = SQLException.class)
void retryServiceWithRecovery(String sql) throws SQLException;

/**
* Customizing @Retryable’s Behavior
* <p/>
* There will be up to two attempts and a delay of 100 milliseconds.
* @param sql, the SQL query
* @throws SQLException if the SQL query is empty
*/
@Retryable(retryFor = SQLException.class , maxAttempts = 2, backoff = @Backoff(delay = 100))
void retryServiceWithCustomization(String sql) throws SQLException;

/**
* We can also use properties in the @Retryable annotation.
* <p/>
* Please note that we are now using maxAttemptsExpression and delayExpression instead of maxAttempts and delay.
* @param sql, the SQL query
* @throws SQLException if the SQL query is empty
*/
@Retryable(retryFor = SQLException.class, maxAttemptsExpression = "${retry.maxAttempts}",
backoff = @Backoff(delayExpression = "${retry.maxDelay}"))
void retryServiceWithExternalConfiguration(String sql) throws SQLException;

@Recover
void recover(SQLException e, String sql);

void templateRetryService();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package org.savea.springretry.services.impl;

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.savea.springretry.services.MyService;
import org.springframework.stereotype.Service;

import java.sql.SQLException;

@Service
@Slf4j
public class MyServiceImpl implements MyService {

@Override
public void retryService() {
log.info("throw RuntimeException in method retryService()");
throw new RuntimeException();
}

@Override
public void retryServiceWithRecovery(String sql) throws SQLException {
if (StringUtils.isEmpty(sql)) {
log.info("throw SQLException in method retryServiceWithRecovery()");
throw new SQLException();
}
}

@Override
public void retryServiceWithCustomization(String sql) throws SQLException {
if (StringUtils.isEmpty(sql)) {
log.info("throw SQLException in method retryServiceWithCustomization()");
throw new SQLException();
}
}

@Override
public void retryServiceWithExternalConfiguration(String sql) throws SQLException {
if (StringUtils.isEmpty(sql)) {
log.info("throw SQLException in method retryServiceWithExternalConfiguration()");
throw new SQLException();
}
}

@Override
public void recover(SQLException e, String sql) {
log.info("In recover method");
}

@Override
public void templateRetryService() {
log.info("throw RuntimeException in method templateRetryService()");
throw new RuntimeException();
}
}
2 changes: 2 additions & 0 deletions springretry/src/main/resources/retryConfig.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
retry.maxAttempts=2
retry.maxDelay=100
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package com.savea;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.savea.springretry.configs.AppConfig;
import org.savea.springretry.services.MyService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.support.AnnotationConfigContextLoader;

import java.sql.SQLException;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = AppConfig.class, loader = AnnotationConfigContextLoader.class)
public class SpringRetryIntegrationTest {

@SpyBean
private MyService myService;
@Value("${retry.maxAttempts}")
private String maxAttempts;

@Autowired
private RetryTemplate retryTemplate;

/**
* Expect to see the method retried three times in intervals of 1 second.
* You should see three error logs in the console relating to the {@link RuntimeException}.
*/
@Test(expected = RuntimeException.class)
public void givenRetryService_whenCallWithException_thenRetry() {
myService.retryService();
}

@Test
public void givenRetryServiceWithRecovery_whenCallWithException_thenRetryRecover() throws SQLException {
myService.retryServiceWithRecovery(null);
verify(myService, times(3)).retryServiceWithRecovery(any());
}

@Test
public void givenRetryServiceWithCustomization_whenCallWithException_thenRetryRecover() throws SQLException {
myService.retryServiceWithCustomization(null);

/*
* This test method is used to verify the behavior of the `retryServiceWithCustomization` method in the `MyService` class.
*
* The `verify` method from Mockito is used to check that the `retryServiceWithCustomization` method of the `myService` object
* was called a certain number of times, as specified by the `maxAttempts` value.
*
* The `times` method from Mockito is used with `Integer.parseInt(maxAttempts)`, which converts the `maxAttempts` string to an integer.
*
* The `any()` method from Mockito is used as an argument to the `retryServiceWithCustomization` method. It is a flexible argument matcher
* that accepts any argument of the given type.
*/
verify(myService, times(Integer.parseInt(maxAttempts))).retryServiceWithCustomization(any());
}

@Test
public void givenRetryServiceWithExternalConfiguration_whenCallWithException_thenRetryRecover() throws SQLException {
myService.retryServiceWithExternalConfiguration(null);
verify(myService, times(Integer.parseInt(maxAttempts))).retryServiceWithExternalConfiguration(any());
}

/**
* Testing the use of the retry template.
*/
@Test(expected = RuntimeException.class)
public void givenTemplateRetryService_whenCallWithException_thenRetry() {
retryTemplate.execute(arg0 -> {
myService.templateRetryService();
return null;
});
}
}

0 comments on commit f33e6c4

Please sign in to comment.