Spring Retry の include, exclude, ExhaustedRetryException の挙動を確認する

## 概要

- Spring Boot + Spring Retry によるサンプルプログラムで挙動を確認する
- @Retryable アノテーションにてリトライ対象の例外を include で指定する
- @Retryable アノテーションにてリトライ対象ではない例外を exclude で指定する
- @Recover で捕捉できない例外がある場合に ExhaustedRetryException が発生するのを確認する

## サンプルプログラムのソースコード一覧

```
├── pom.xml
└── src
    └── main
        └── java
            └── info
                └── maigo
                    └── lab
                        └── sample
                            └── retry
                                └── exhausted
                                    ├── HogeApplication.java
                                    ├── HogeController.java
                                    ├── HogeException.java
                                    ├── HogeHogeException.java
                                    └── HogeService.java
```

## 動作確認環境

- macOS Mojave
- OpenJDK 11.0.2
- Spring Boot 2.2.0 M4
- Spring Retry 1.2.4

## Maven のビルド用ファイル pom.xml

今回は Maven でビルドを実行する。
pom.xml は [Spring Initializr](https://start.spring.io/) で生成したものをベースとした。
Spring Retry を使用するため dependencies に spring-retry を追加する。
また、実行時に AOP クラスが必要なため spring-boot-starter-aop を追加する。

```xml
<?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.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.0.M4</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <groupId>info.maigo.lab</groupId>
    <artifactId>sample.retry.exhausted</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>sample.retry.exhausted</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>11</java.version>
    </properties>

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

        <!-- https://mvnrepository.com/artifact/org.springframework.retry/spring-retry -->
        <dependency>
            <groupId>org.springframework.retry</groupId>
            <artifactId>spring-retry</artifactId>
            <version>1.2.4.RELEASE</version>
        </dependency>

        <!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-aop -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

    <repositories>
        <repository>
            <id>spring-snapshots</id>
            <name>Spring Snapshots</name>
            <url>https://repo.spring.io/snapshot</url>
            <snapshots>
                <enabled>true</enabled>
            </snapshots>
        </repository>
        <repository>
            <id>spring-milestones</id>
            <name>Spring Milestones</name>
            <url>https://repo.spring.io/milestone</url>
        </repository>
    </repositories>
    <pluginRepositories>
        <pluginRepository>
            <id>spring-snapshots</id>
            <name>Spring Snapshots</name>
            <url>https://repo.spring.io/snapshot</url>
            <snapshots>
                <enabled>true</enabled>
            </snapshots>
        </pluginRepository>
        <pluginRepository>
            <id>spring-milestones</id>
            <name>Spring Milestones</name>
            <url>https://repo.spring.io/milestone</url>
        </pluginRepository>
    </pluginRepositories>

</project>
```

## ビルドとサーバ起動

mvn package コマンドで JAR ファイルを生成する。

```
$ mvn package
```

java コマンドで JAR ファイルを指定してサーバを起動する。

```
$ java -jar target/sample.retry.exhausted-0.0.1-SNAPSHOT.jar

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::             (v2.2.0.M4)
```

## ソースコード

### HogeApplication.java

Spring Boot による起動エントリクラス。
Spring Retry を使用するため @EnableRetry アノテーションを指定している。

```java
package info.maigo.lab.sample.retry.exhausted;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.retry.annotation.EnableRetry;

@SpringBootApplication
@EnableRetry
public class HogeApplication {

    public static void main(String[] args) {
        SpringApplication.run(HogeApplication.class, args);
    }

}
```

### HogeController.java

HTTPリクエストを受け取ってレスポンスを返す Controller クラス。
例外が発生した際は @ExceptionHandler アノテーションを指定したメソッドにて処理する。

```java
package info.maigo.lab.sample.retry.exhausted;

import java.util.HashMap;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HogeController {

    @Autowired
    private HogeService hogeService;

    @RequestMapping("/")
    public Map<String, Object> index(@RequestParam(name = "name", defaultValue = "java.lang.RuntimeException") String name) throws Exception {
        return new HashMap<String, Object>() {
            {
                put("result", hogeService.invoke(name));
            }
        };
    }

    @ExceptionHandler(Exception.class)
    public Map<String, Object> handleException(HttpServletRequest req, Exception e) {
        return new HashMap<String, Object>() {
            {
                put("handleException", e.getClass().getName() + " / " + e.getMessage());
            }
        };
    }
}
```

### HogeException.java

Exception を継承しているだけの例外クラス。

```java
package info.maigo.lab.sample.retry.exhausted;

public class HogeException extends Exception {
}
```

### HogeHogeException.java

HogeException を継承している例外クラス。

```java
package info.maigo.lab.sample.retry.exhausted;

public class HogeHogeException extends HogeException {
}
```

### HogeService.java

Service クラス。
invoke メソッドでは、指定された文字列を元に例外オブジェクトを生成し throw する。
invoke メソッドには @Retryable アノテーションを指定している。include でリトライ対象となる例外の型を指定し、exclude でリトライ対象としない例外の型を指定している。maxAttempts でリトライ回数を指定。backoffでリトライまでの待ち時間を指定。
@Recover アノテーションを指定したメソッドは、指定回数試行しても例外が発生してしまった場合にコールされる (リトライ対象でない例外でも型に合えばこのメソッドをコールする)。

```java
package info.maigo.lab.sample.retry.exhausted;

import java.lang.reflect.Constructor;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.retry.annotation.Recover;

@Service
public class HogeService {

    @Retryable(
        include = {HogeException.class},
        exclude = {HogeHogeException.class},
        maxAttempts = 3,
        backoff = @Backoff(delay = 1000))
    public String invoke(String name) throws Exception {
        System.out.println("HogeService#invoke: " + name);
        Class cls = Class.forName(name);
        Constructor cnst = cls.getDeclaredConstructor();
        Exception e = (Exception) cnst.newInstance();
        throw e;
    }

    @Recover
    public String recover(HogeException e, String name) {
        System.out.println("HogeService#recover: " + name);
        return "HogeService#recover: " + e.getClass().getName();
    }
}
```

## 実行例

### HogeException を発生させてリトライさせる

起動したサーバに curl でアクセスすると JSON が出力される。
HogeException が発生するよう指定する。
recover メソッドで HogeException の文字列表現が返されている。

```
$ curl http://localhost:8080/?name=info.maigo.lab.sample.retry.exhausted.HogeException
{"result":"HogeService#recover: info.maigo.lab.sample.retry.exhausted.HogeException"}
```

サーバ側の標準出力ログを見る。
リトライが実行されて invoke メソッドが3回コールされたあと、recover メソッドがコールされていることがわかる。

```
HogeService#invoke: info.maigo.lab.sample.retry.exhausted.HogeException
HogeService#invoke: info.maigo.lab.sample.retry.exhausted.HogeException
HogeService#invoke: info.maigo.lab.sample.retry.exhausted.HogeException
HogeService#recover: info.maigo.lab.sample.retry.exhausted.HogeException
```

HogeException は @Retryable アノテーションの include に指定されているため、リトライ処理が実行される。
最後のリトライで例外が発生した場合には @Recover アノテーションが指定されたメソッドがコールされる。

### HogeHogeException を発生させてリトライさせない

HogeHogeException が発生するよう指定して curl でアクセスする。
recover メソッドで HogeHogeException の文字列表現が返されている。

```
$ curl http://localhost:8080/?name=info.maigo.lab.sample.retry.exhausted.HogeHogeException
{"result":"HogeService#recover: info.maigo.lab.sample.retry.exhausted.HogeHogeException"}
```

サーバ側の標準出力ログを見る。
invoke メソッドが1回だけコールされたあと、recover メソッドがコールされていることがわかる。

```
HogeService#invoke: info.maigo.lab.sample.retry.exhausted.HogeHogeException
HogeService#recover: info.maigo.lab.sample.retry.exhausted.HogeHogeException
```

HogeHogeException は @Retryable アノテーションの exclude に指定されているため、リトライ処理はされない。
invoke メソッドを1回コールして HogeHogeException が発生し、 recover メソッドがコールされて結果の文字列が返されている。
また、HogeHogeException は @Retryable アノテーションの include で指定されている HogeException のサブクラスのため、exclude にて HogeHogeException を指定していなければリトライ対象となる。

### @Recover で捕捉できない例外が投げられると ExhaustedRetryException が発生する

java.lang.Exception が発生するよう指定して curl でアクセスする。
recover メソッドで捕捉できずに org.springframework.retry.ExhaustedRetryException 例外が投げられる。

```
$ curl http://localhost:8080/?name=java.lang.Exception
{"handleException":"org.springframework.retry.ExhaustedRetryException / Cannot locate recovery method; nested exception is java.lang.Exception"}
```

サーバ側の標準出力ログを見る。
invoke メソッドが1回だけコールされていることがわかる。recover メソッドはコールされていない。

```
HogeService#invoke: java.lang.Exception
```

java.lang.Exception は @Retryable アノテーションの include に指定されていないため、リトライ処理はされない。
また、 @Recover アノテーションが指定されたメソッドにて捕捉できる例外の型ではないため、ExhaustedRetryException が投げられる。
今回の場合、ExhaustedRetryException クラスの getCause メソッドで、原因となった例外 java.lang.Exception のオブジェクトを取得することができる。

## 参考資料

- [GitHub \- spring\-projects/spring\-retry](https://github.com/spring-projects/spring-retry)
- [org\.springframework\.retry:spring\-retry:1\.2\.4\.RELEASE API Doc :: Javadoc\.IO](https://www.javadoc.io/doc/org.springframework.retry/spring-retry/1.2.4.RELEASE)
- [Retryable \(Spring Retry 1\.2\.4\.RELEASE API\)](https://static.javadoc.io/org.springframework.retry/spring-retry/1.2.4.RELEASE/index.html?org/springframework/retry/annotation/Retryable.html)
- [ExhaustedRetryException \(Spring Retry 1\.2\.4\.RELEASE API\)](https://static.javadoc.io/org.springframework.retry/spring-retry/1.2.4.RELEASE/index.html?org/springframework/retry/ExhaustedRetryException.html)
- [@Retryable exclude option does not work as expected · Issue \#47 · spring\-projects/spring\-retry · GitHub](https://github.com/spring-projects/spring-retry/issues/47)
- [Spring Retry を利用して宣言型のリトライ処理を実装する \- Qiita](https://qiita.com/niwasawa/items/4938f5a79fa225471dd6)