LoginSignup
1
0

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-12-24

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

TL;DR

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

本文

共通で利用するコードについて

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

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のリトライ機能を用いた以下のようなコードが引っかかることが多いです。

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が実行される危険性が有ります。
この問題は以下のように再現できます。

問題を再現した例
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でこの問題が起きてしまうと、データ不整合が発生する可能性も有ります。

対処法

リトライ処理を以下のように変更し、Retryfilterを付けることで、通信エラーに関してのみリトライできるようになります。
WebClientExceptionWebClientが通信エラー(ステータスが4xxまたは5xxだった)時にthrowされるExceptionです。

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 })
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0