SpringのRestTemplateによるHTTPリクエストのリトライをClientHttpRequestInterceptorで実装する

SpringのHTTPクライアントでRestTemplateをまだ使うことは多いと思います。

HTTPクライアントでリトライ処理を入れるのは定石とも言えますが、RestTemplateでリトライ処理を行う方法を検索すると、

がよく見つかります。

spring-retryでもresilience4j-retryでも、アノテーションによってDeclarative(宣言的)に処理全体をリトライするか、ラムダで囲った処理をImperative(命令的)にリトライする方法が提供されています。 どちらの場合もHTTPクライアントの呼び出し処理に対して明示的にリトライ処理を実施するのですが、 もう少し透過的に通信レイヤーでリトライを行いたかったので、RestTemplateClientHttpRequestInterceptorで簡単なリトライ処理を実装しました。

リトライでは一般的にBackoffの実装が必要です。invervalをfixed(固定)にするかexponential(指数関数的)にするかの選択肢があることが多いです。

SpringではBackoffインタフェースが用意されており、FixedBackoffExponentialBackOffの実装が使えますので、Backoffにはこれをそのまま使えば良いです。

Interceptorの中でHTTP Response Statusがリトライ可能であれば、sleepしてもう一度リクエストを送るというループを実装します。

/*
 * Copyright (C) 2023 Toshiaki Maki <makingx@gmail.com>
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *         http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package am.ik.spring.http.client;

import java.io.IOException;
import java.net.ConnectException;
import java.net.SocketTimeoutException;
import java.net.URLConnection;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.util.backoff.BackOff;
import org.springframework.util.backoff.BackOffExecution;

public class RetryableClientHttpRequestInterceptor implements ClientHttpRequestInterceptor {

	private final BackOff backOff;

	private final Set<Integer> retryableResponseStatuses;

	private final boolean retryClientTimeout;

	public static Set<Integer> DEFAULT_RETRYABLE_RESPONSE_STATUSES = Collections
			.unmodifiableSet(new HashSet<>(Arrays.asList( //
					408 /* Request Timeout */, //
					425 /* Too Early */, //
					429 /* Too Many Requests */, //
					500 /* Internal Server Error */, //
					502 /* Bad Gateway */, //
					503 /* Service Unavailable */, //
					504 /* Gateway Timeout */
			)));

	private static final int MAX_ATTEMPTS = 100;

	private final Log log = LogFactory.getLog(RetryableClientHttpRequestInterceptor.class);

	public RetryableClientHttpRequestInterceptor(BackOff backOff) {
		this(backOff, DEFAULT_RETRYABLE_RESPONSE_STATUSES, true);
	}

	public RetryableClientHttpRequestInterceptor(BackOff backOff, Set<Integer> retryableResponseStatuses) {
		this(backOff, retryableResponseStatuses, true);
	}

	public RetryableClientHttpRequestInterceptor(BackOff backOff, Set<Integer> retryableResponseStatuses,
			boolean retryClientTimeout) {
		this.backOff = backOff;
		this.retryableResponseStatuses = retryableResponseStatuses;
		this.retryClientTimeout = retryClientTimeout;
	}

	@Override
	public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution)
			throws IOException {
		final BackOffExecution backOffExecution = this.backOff.start();
		for (int i = 1; i <= MAX_ATTEMPTS; i++) {
			final long backOff = backOffExecution.nextBackOff();
			try {
				if (log.isDebugEnabled()) {
					log.debug(String.format("Request %d: %s %s", i, request.getMethod(), request.getURI()));
					log.debug(String.format("Request %d: %s", i, request.getHeaders()));
				}
				final ClientHttpResponse response = execution.execute(request, body);
				if (log.isDebugEnabled()) {
					log.debug(String.format("Response %d: %s", i, response.getStatusCode()));
					log.debug(String.format("Response %d: %s", i, response.getHeaders()));
				}
				if (!isRetryableHttpStatus(() -> response.getStatusCode().isError(),
						() -> response.getStatusCode().value())) {
					return response;
				}
				if (backOff == BackOffExecution.STOP) {
					log.warn("No longer retryable");
					return response;
				}
			}
			catch (IOException e) {
				if (!isRetryableIOException(e)) {
					throw e;
				}
				else if (backOff == BackOffExecution.STOP) {
					log.warn("No longer retryable", e);
					throw e;
				}
				else {
					log.info(e.getMessage());
				}
			}
			if (log.isInfoEnabled()) {
				log.info(String.format("Wait interval (%s)", backOffExecution));
			}
			try {
				Thread.sleep(backOff);
			}
			catch (InterruptedException ex) {
				Thread.currentThread().interrupt();
			}
		}
		throw new IllegalStateException("Maximum number of attempts reached!");
	}

	private boolean isRetryableIOException(IOException e) {
		return (e instanceof ConnectException) || isRetryableClientTimeout(e);
	}

	/**
	 * @see {@link URLConnection#setConnectTimeout(int)}
	 * @see {@link URLConnection#setReadTimeout(int)}
	 */
	private boolean isRetryableClientTimeout(IOException e) {
		return (e instanceof SocketTimeoutException) && this.retryClientTimeout;
	}

	private boolean isRetryableHttpStatus(ErrorSupplier errorSupplier, StatusSupplier statusSupplier)
			throws IOException {
		return errorSupplier.isError() && this.retryableResponseStatuses.contains(statusSupplier.getStatus());
	}

	// to work with both Spring 5 (HttpStatus) and 6 (HttpStatusCode)
	private interface ErrorSupplier {

		boolean isError() throws IOException;

	}

	private interface StatusSupplier {

		int getStatus() throws IOException;

	}

}

このソースコードはSpring 5でもSpring 6でもコンパイル可能です。ただし、response.getStatusCode()の返り値の型がSpring 6の場合はHttpStatusCodeで、Spring 5の場合はHttpStatusで異なるため、バイナリ互換ではありません。

Spring Bootの場合は、RestTemplateBuilderを使って、次のようにRestTemplateに埋め込めます。

private final RestTemplate restTemplate;

public MyClient(RestTemplateBuilder builder){
	this.restTemplate = builder
		.rootUri("http://localhost:9999")
		.interceptors(new RetryableClientHttpRequestInterceptor(new FixedBackOff(100, 3)))
		.build();
}

2回連続で503エラーを返し、3回目のアクセスで200を返すサーバーに対して、次のコードでアクセスすると、

ResponseEntity<String> response = this.restTemplate.getForEntity("/hello", String.class);

次のようなログが出力され、3回目のアクセスまでリトライしていることがわかります。

2023-04-28T16:24:34.871+09:00 DEBUG 15864 --- [           main] .c.RetryableClientHttpRequestInterceptor : Request 1: GET http://localhost:9999/hello
2023-04-28T16:24:34.872+09:00 DEBUG 15864 --- [           main] .c.RetryableClientHttpRequestInterceptor : Request 1: [Accept:"text/plain, application/json, application/*+json, */*", Content-Length:"0"]
2023-04-28T16:24:34.902+09:00 DEBUG 15864 --- [           main] .c.RetryableClientHttpRequestInterceptor : Response 1: 503 SERVICE_UNAVAILABLE
2023-04-28T16:24:34.902+09:00 DEBUG 15864 --- [           main] .c.RetryableClientHttpRequestInterceptor : Response 1: [Date:"Fri, 28 Apr 2023 07:24:34 GMT", Content-length:"5"]
2023-04-28T16:24:34.904+09:00  INFO 15864 --- [           main] .c.RetryableClientHttpRequestInterceptor : Wait interval (FixedBackOff{interval=1000, currentAttempts=1, maxAttempts=3})
2023-04-28T16:24:35.908+09:00 DEBUG 15864 --- [           main] .c.RetryableClientHttpRequestInterceptor : Request 2: GET http://localhost:9999/hello
2023-04-28T16:24:35.908+09:00 DEBUG 15864 --- [           main] .c.RetryableClientHttpRequestInterceptor : Request 2: [Accept:"text/plain, application/json, application/*+json, */*", Content-Length:"0"]
2023-04-28T16:24:35.910+09:00 DEBUG 15864 --- [           main] .c.RetryableClientHttpRequestInterceptor : Response 2: 503 SERVICE_UNAVAILABLE
2023-04-28T16:24:35.910+09:00 DEBUG 15864 --- [           main] .c.RetryableClientHttpRequestInterceptor : Response 2: [Date:"Fri, 28 Apr 2023 07:24:35 GMT", Content-length:"5"]
2023-04-28T16:24:35.910+09:00  INFO 15864 --- [           main] .c.RetryableClientHttpRequestInterceptor : Wait interval (FixedBackOff{interval=1000, currentAttempts=2, maxAttempts=3})
2023-04-28T16:24:36.915+09:00 DEBUG 15864 --- [           main] .c.RetryableClientHttpRequestInterceptor : Request 3: GET http://localhost:9999/hello
2023-04-28T16:24:36.915+09:00 DEBUG 15864 --- [           main] .c.RetryableClientHttpRequestInterceptor : Request 3: [Accept:"text/plain, application/json, application/*+json, */*", Content-Length:"0"]
2023-04-28T16:24:36.917+09:00 DEBUG 15864 --- [           main] .c.RetryableClientHttpRequestInterceptor : Response 3: 200 OK
2023-04-28T16:24:36.917+09:00 DEBUG 15864 --- [           main] .c.RetryableClientHttpRequestInterceptor : Response 3: [Date:"Fri, 28 Apr 2023 07:24:36 GMT", Content-length:"12"]

上記のコードをコピペしてもいいし、Maven Centralからも利用可能です。

Spring 6+ / Spring Boot 3+の場合

<dependency>
	<groupId>am.ik.spring</groupId>
	<artifactId>retryable-client-http-request-interceptor</artifactId>
	<version>0.2.3</version>
</dependency>

Spring 5 / Spring Boot 2の場合

<dependency>
	<groupId>am.ik.spring</groupId>
	<artifactId>retryable-client-http-request-interceptor-spring5</artifactId>
	<version>0.2.3</version>
</dependency>

GitHubレポジトリはこちらです
https://github.com/making/retryable-client-http-request-interceptor