【Spring WebFlux】WebClientでリトライする時はfilterを忘れずに！

この記事は`Spring Advent Calendar 2022`の24日目の記事になりました。

https://qiita.com/advent-calendar/2022/spring

# TL;DR
- `WebClient`のリトライ処理で、頻出のサンプルはそのまま使うとリクエスト失敗以外の理由でもリトライされる場合が有る
  - 特に取得した`JSON`のデシリアライズに失敗した場合
- 冪等性の無い`API`に対してこの問題を踏むと、データ不整合が発生しうる
- `Retry`に`filter`を付けることでこの問題は回避できる

# 本文
## 共通で利用するコードについて
以降の解説では、以下の`WebClient`及び`DTO`を利用します。
諸事情から`Kotlin`で書いていますが、`Java`で書いた場合と大差有るような内容は有りません。

```kotlin
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.springframework.http.MediaType
import org.springframework.http.codec.json.Jackson2JsonDecoder
import org.springframework.web.reactive.function.client.WebClient

val objectMapper: ObjectMapper = jacksonObjectMapper().apply {
    // 不明なプロパティは無視
    disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
}

val client: WebClient = WebClient.builder()
    // 今回はPOSTしないのでデシリアライズ設定のみ
    .codecs {
        it.defaultCodecs().jackson2JsonDecoder(Jackson2JsonDecoder(objectMapper, MediaType.APPLICATION_JSON))
    }
    .build()

// DTOは一旦APIレスポンスの一部のみ
data class UserDto(
    val login: String,
    val id: Int,
    val url: String
)
```

## 頻出のリトライ処理の問題点について
「Spring WebClient retry」というようにググった場合、`Reactor`のリトライ機能を用いた以下のようなコードが引っかかることが多いです。

```kotlin
import java.time.Duration
import reactor.core.publisher.Mono
import reactor.util.retry.Retry

val userDtoMono: Mono<UserDto> = client.get()
    .uri("https://api.github.com/users/k163377") // 筆者のGitHubユーザー情報の取得
    .retrieve()
    .bodyToMono(UserDto::class.java)
    .retryWhen(Retry.fixedDelay(3, Duration.ofSeconds(1))) // 1秒間隔で3回リトライ
```

一方、この書き方では、`API`通信が成功した後の処理で起きたエラーでもリトライされ、結果複数回`API`が実行される危険性が有ります。
この問題は以下のように再現できます。

```kotlin:問題を再現した例
val userDtoMono: Mono<UserDto> = client.get()
    .uri("https://api.github.com/users/k163377") // 筆者のGitHubユーザー情報の取得
    .retrieve()
    .bodyToMono(UserDto::class.java)
    .doOnNext { println("API通信成功！$it") }
    .map <UserDto> { throw Exception("何かしらのエラー！") } // API通信成功後にエラーにしてみる
    .doOnError { println(it.message) }
    .retryWhen(Retry.fixedDelay(3, Duration.ofSeconds(1))) // 1秒間隔で3回リトライ
```

実行結果は以下のようになります。
`API`が複数回実行されてしまっていることが分かります。

```:実行結果
API通信成功！UserDto(login=k163377, id=24751011, url=https://api.github.com/users/k163377)
何かしらのエラー！
API通信成功！UserDto(login=k163377, id=24751011, url=https://api.github.com/users/k163377)
何かしらのエラー！
API通信成功！UserDto(login=k163377, id=24751011, url=https://api.github.com/users/k163377)
何かしらのエラー！
API通信成功！UserDto(login=k163377, id=24751011, url=https://api.github.com/users/k163377)
何かしらのエラー！
```

例えば何かを作成するような`API`でこの問題が起きてしまうと、データ不整合が発生する可能性も有ります。

## 対処法
リトライ処理を以下のように変更し、`Retry`に`filter`を付けることで、通信エラーに関してのみリトライできるようになります。
`WebClientException`は`WebClient`が通信エラー（ステータスが4xxまたは5xxだった）時に`throw`される`Exception`です。

```diff_kotlin
import org.springframework.web.reactive.function.client.WebClientException

-    .retryWhen(Retry.fixedDelay(3, Duration.ofSeconds(1))) // 1秒間隔で3回リトライ
+    // WebClientExceptionだった場合1秒間隔で3回リトライ
+    .retryWhen(Retry.fixedDelay(3, Duration.ofSeconds(1)).filter { it is WebClientException })
```