Spring Boot x Resilience4j CircuitBreaker、Retryを試してみる

# 実現したい事
- Spring Boot、Resilience4j、RestTemplate を使用する
- 連携先のサービスから通信エラー、500エラーが返ってきた場合、リトライする
- リトライも失敗した場合、代わりの処理を実行する
- リクエストの失敗率が閾値を超えた場合、連携先のサービスへはリクエストを投げずに代わりの処理を実行する
- 連携先のサービスが復旧した場合、連携先のサービスへリクエストを投げるように自動で復旧する

# 参考にした記事
https://resilience4j.readme.io/docs/getting-started-3

# 構成
![distributed-tracing.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149245/afb99a5e-fe96-41a7-4b0f-b03a316ec94e.png)

# Spring Boot アプリケーションの作成
## api-service の実装
構成図の api-service に該当するアプリケーションを作成します。
``` build.gradle.kts
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
	id("org.springframework.boot") version "3.2.0"
	id("io.spring.dependency-management") version "1.1.4"
	kotlin("jvm") version "1.9.20"
	kotlin("plugin.spring") version "1.9.20"
}

group = "com.example"
version = "0.0.1-SNAPSHOT"

java {
	sourceCompatibility = JavaVersion.VERSION_17
}

repositories {
	mavenCentral()
}

dependencies {
	implementation("org.springframework.boot:spring-boot-starter")
	implementation("org.springframework.boot:spring-boot-starter-web")
	implementation("org.springframework.boot:spring-boot-starter-aop")
	implementation("org.springframework.boot:spring-boot-starter-actuator")
	implementation("org.jetbrains.kotlin:kotlin-reflect")
	implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
	//	Micrometer Observation APIをOpenTelemetryにブリッジするライブラリ
	implementation("io.micrometer:micrometer-tracing-bridge-otel:1.2.0")
	// トレース情報を Zipkin に送信するライブラリ
	implementation("io.opentelemetry:opentelemetry-exporter-zipkin:1.32.0")
	implementation("io.github.resilience4j:resilience4j-spring-boot3:2.1.0")
	testImplementation("org.springframework.boot:spring-boot-starter-test")
}

tasks.withType<KotlinCompile> {
	kotlinOptions {
		freeCompilerArgs += "-Xjsr305=strict"
		jvmTarget = "17"
	}
}

tasks.withType<Test> {
	useJUnitPlatform()
}
```

``` application.yml
spring:
  application:
    name: api-service customer-service # Zipkin に送信されるサービス名。同一のサービスと認識されてしまう為、一意である必要がある

management:
  tracing:
    sampling:
      probability: 1.0 # トレース情報の送信割合。1.0 で全てのトレース情報が送信される
  health:
    circuitbreakers.enabled: true # CircuitBreakerのヘルスインジケーターを有効にする
  endpoints:
    web:
      exposure:
        include: "health,circuitbreakers,metrics"

resilience4j.circuitbreaker:
  circuitBreakerAspectOrder: 1 # 優先順位の変更。retry より後に実行されるようにする
  instances:
    customer-service: # 設定したい接続先システムを指定
      registerHealthIndicator: true # ヘルスチェックエンドでサーキットブレーカーのステータスが確認可能になる
      slidingWindowType: COUNT_BASED # slidingWindowTypeをカウントベースにする
      slidingWindowSize: 3 # ここで指定した数のコール数を保持してエラーレートの計算に利用する
      minimumNumberOfCalls: 3 # エラーレートを計算するのに必要な最小のコール数
      permittedNumberOfCallsInHalfOpenState: 2 # HALF_OPENの時に許可される呼び出しの数。こちらに指定した回数を実行して、failureRateThreshold を満たさない場合は、OPEN に移行する
      automaticTransitionFromOpenToHalfOpenEnabled: true # trueだと自動でHALF_OPENに移行する
      waitDurationInOpenState: 10s # OPENからHALF_OPENに移行する前に待機する時間
      failureRateThreshold: 100 # 失敗率の閾値。この数値を超えて失敗しているとOPENに移行する
      # ここで指定したExceptionが発生すると失敗としてカウントする
      recordExceptions:
        - org.springframework.web.client.HttpServerErrorException
        - org.springframework.web.client.RestClientException

resilience4j.retry:
  retryAspectOrder: 2 # 優先順位の変更。circuitbreaker より先に実行されるようにする
  instances:
    customer-service:
      maxAttempts: 3 # 最大試行回数
      waitDuration: 1s
      retryExceptions:
        - org.springframework.web.client.HttpServerErrorException
        - org.springframework.web.client.RestClientException

logging:
  level:
    root: INFO
```
- `recordExceptions`で`RestClientException` を指定している理由は以下になります。
https://resilience4j.readme.io/docs/getting-started-3#fallback-methods
https://stackoverflow.com/questions/76940489/spring-boot-resilience4j-circuit-breaker-fallback-method-not-being-called
- デフォルトでは、retry が circuitbreaker より後に実行される為、優先順位を指定しております。
https://resilience4j.readme.io/docs/getting-started-3#aspect-order


``` CustomerController.kt
import com.example.sleuthdemoclient.service.CustomerService
import org.apache.commons.logging.LogFactory
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
class CustomerController(
    private val customerService: CustomerService
) {
    private val logger = LogFactory.getLog(CustomerController::class.java)

    @RequestMapping("/customer")
    fun get(): HelloRes {
        logger.info("get() has been called")
        val result = customerService.get()
        return HelloRes(
            result.value
        )
    }

    class HelloRes(
        val value: String
    )
}
```

``` CustomerService.kt
import io.github.resilience4j.circuitbreaker.CallNotPermittedException
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker
import io.github.resilience4j.retry.annotation.Retry
import org.apache.commons.logging.LogFactory
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Service
import org.springframework.web.client.HttpServerErrorException
import org.springframework.web.client.RestClientException
import org.springframework.web.client.RestTemplate
import org.springframework.web.client.getForEntity
import org.springframework.web.util.UriComponentsBuilder

@Service
class CustomerService(
    private val restTemplate: RestTemplate
) {
    private val logger = LogFactory.getLog(CustomerService::class.java)

    @Retry(name = "customer-service")
    @CircuitBreaker(name = "customer-service", fallbackMethod = "fallback")
    fun get(): CustomerRes {
        logger.info("get()")
        val uri = UriComponentsBuilder.fromUriString("http://localhost:8081")
            .path("customer")
            .toUriString()

        val response: ResponseEntity<CustomerRes> = restTemplate.getForEntity(
            uri,
            CustomerRes::class
        )

        return response.body!!
    }

    private fun fallback(exception: HttpServerErrorException): CustomerRes {
        return CustomerRes("state が CLOSE または HALF_OPEN の状態で失敗")
    }

    private fun fallback(exception: RestClientException): CustomerRes {
        return CustomerRes("state が CLOSE または HALF_OPEN の状態で通信エラー")
    }

    private fun fallback(exception: CallNotPermittedException): CustomerRes {
        return CustomerRes("state が OPEN")
    }
}

data class CustomerRes(
    val value: String
)
```
- state については、以下に説明があります。
https://resilience4j.readme.io/docs/circuitbreaker#introduction

# 動作確認
### Retry の実行が CircuitBreaker の failure rate に反映されるか確認、fallback が動作することの確認
結果としては最後のリクエストのみが反映されるようです。
- 実行前の failure rate の確認
http://localhost:8080/actuator/metrics/resilience4j.circuitbreaker.failure.rate
```
{
	"name": "resilience4j.circuitbreaker.failure.rate",
	"description": "The failure rate of the circuit breaker",
	"measurements": [
		{
			"statistic": "VALUE",
			"value": -1
		}
	],
	"availableTags": [
		{
			"tag": "name",
			"values": [
				"customer-service"
			]
		}
	]
}
```
- customer-service を停止して、必ずリトライされるようにする
- http://localhost:8080/customer にアクセス
- 2回リトライが発生し、合計3回リクエストを投げましたが、failure rate は -1のままでした。`minimumNumberOfCalls`を3で設定しているので、Retry が failure rate に反映されている場合は-1以外になります。
- api-service からのレスポンスは以下の結果が返ってきた為、fallback も期待通りに動いているようです。
```
{"value":"state が CLOSE または HALF_OPEN の状態で通信エラー"}
```

### failureRateThreshold に指定した失敗率に達した場合に、OPEN になることの確認
- http://localhost:8080/customer に3回アクセス
- failure rate が100に変わりました。
```
{
	"name": "resilience4j.circuitbreaker.failure.rate",
	"description": "The failure rate of the circuit breaker",
	"measurements": [
		{
			"statistic": "VALUE",
			"value": 100
		}
	],
	"availableTags": [
		{
			"tag": "name",
			"values": [
				"customer-service"
			]
		}
	]
}
```
- CircuitBreaker の state が OPEN になっていることを確認する
- http://localhost:8080/actuator/circuitbreakers
```
{
	"circuitBreakers": {
		"customer-service": {
			"failureRate": "100.0%",
			"slowCallRate": "0.0%",
			"failureRateThreshold": "100.0%",
			"slowCallRateThreshold": "100.0%",
			"bufferedCalls": 3,
			"failedCalls": 3,
			"slowCalls": 0,
			"slowFailedCalls": 0,
			"notPermittedCalls": 0,
			"state": "OPEN"
		}
	}
}
```

### CircuitBreaker の state が OPEN の時に、Retry が実行されるか確認。fallback が動作することの確認
結果としては state が OPEN の場合は、Retry は実行されないようです。
state が CLOSE の場合のlog
![Zipkin_close.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149245/c1734478-b437-8a06-0332-3c24d0343ed8.png)

state が OPEN の場合のlog
customer-service へはリクエストは投げずに即座にレスポンスを返しています。
![Zipkin_open.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149245/77902f1e-c872-0709-4cef-e9baa1868d79.png)

api-service からのレスポンスは以下の結果が返ってきた為、fallback も期待通りに動いているようです。
```
{"value":"state が OPEN"}
```

### OPEN から HALF_OPEN への移行することの確認
- OPEN の状態から、10秒経過すると、HALF_OPENへ移行しました。
- HALF_OPEN の状態で、`permittedNumberOfCallsInHalfOpenState`に指定されている回数アクセスした場合に、`failureRateThreshold`を満たすと CLOSE へ移行し、満たさない場合は OPEN へ移行するようです。