kagamihogeの日記

kagamihogeの日記です。

HTTP Interface(WebClient)のリトライ

build.gradle

plugins {
  id 'java'
  id 'org.springframework.boot' version '3.0.6'
  id 'io.spring.dependency-management' version '1.1.0'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
  compileOnly {
    extendsFrom annotationProcessor
  }
}

repositories {
  mavenCentral()
}

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-web'
  implementation 'org.springframework.boot:spring-boot-starter-webflux'
  implementation 'org.springframework.boot:spring-boot-starter-aop'
  implementation 'org.springframework.retry:spring-retry'
  
  
  compileOnly 'org.projectlombok:lombok'
  developmentOnly 'org.springframework.boot:spring-boot-devtools'
  annotationProcessor 'org.projectlombok:lombok'
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
  testImplementation 'io.projectreactor:reactor-test'
}

tasks.named('test') {
  useJUnitPlatform()
}

config

import java.time.Duration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.support.WebClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;

@Configuration
public class HttpClientConfig {
  @Bean
  public SampleClient sampleClient() {
    WebClient webClient = WebClient
        .builder()
        .baseUrl("http://localhost:8080/")
        .filter(withRetryableRequests())
        .build();

    HttpServiceProxyFactory proxyFactory =
        HttpServiceProxyFactory.builder(WebClientAdapter.forClient(webClient)).build();

    return proxyFactory.createClient(SampleClient.class);
  }

  private ExchangeFilterFunction withRetryableRequests() {
    return (request, next) -> next.exchange(request)
        .flatMap(clientResponse -> Mono.just(clientResponse)
            .filter(response -> clientResponse.statusCode().isError())
            .flatMap(response -> clientResponse.createException()).flatMap(Mono::error)
            .thenReturn(clientResponse))
        .retryWhen(Retry.fixedDelay(3, Duration.ofSeconds(1L))
            .doAfterRetry(retrySignal -> System.out.println("retrying")));
  }
}

ソースコードhttps://medium.com/@robert.junklewitz/global-webclient-retry-logic-for-all-endpoints-in-spring-webflux-dbbe54206b63 からコピペしている。俺自身がreativeな書き方マッタク知らないので、ぶっちゃけ何やってるのかサッパリわからない。解説部分は以下の通り。

(1) I wrap the whole logic in a dedicated flatMap so that I still have access to the clientResponse object in step 5.
(2) I check if the clientResponse returned with an error code. That means if the response code was a 4xx or 5xx error. If there is no error I just want to proceed to step (5) because thenReturn returns this value after the Mono ended successfully. And if everything gets filtered out, the resulting empty Mono counts as successful.
(3) Now we are in the error processing mode. The clientResponse has a createException() method which will create an exception object (WebClientResponseException) and return a Mono of it. This is important because at this point the response is only a response object, no exception. Usually, when you look at the first code sample I posted, according to the documentation the retrieve() method will eventually map 4xx and 5xx responses to exceptions.
(4) This step also took me a bit to figure out. We have now a Mono of the exception, but the retryWhen still didn’t trigger. This happened because we didn’t trigger an onError signal yet. After all, we didn’t throw this exception. This flatMap will take care of it.
(5) Here we just return the clientResponse in case, we didn’t have an error in the first place, so logic continues as expected.
(6) Here we have our actual retry logic. This retryWhen mustn't be in the inner Mono definition, because you would just retry everything since Mono.just(clientResponse) which would just lead to a useless loop.


https://medium.com/@robert.junklewitz/global-webclient-retry-logic-for-all-endpoints-in-spring-webflux-dbbe54206b63

WebClientは従来のRestTemplate等と異なりレスポンスだけが単に返る訳では無いので、型をあれこれいじくり回す必要がある……らしい。

spring-retry

これとは別にspring-retryを使う方法もある。@EnableRetryとか設定は省略。

import org.springframework.retry.annotation.Retryable;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import org.springframework.web.service.annotation.GetExchange;

public interface SampleClient {  
  @Retryable(retryFor = WebClientResponseException.class, maxAttempts = 3)
  @GetExchange("/sample503")
  String sample503();
}

ただし、WebClient内部でリトライするのと、メソッドの外側からリトライするのとでは挙動が異なるはず。要件次第では合わないケースが出てくるかもしれない。