RestTemplateのエラーハンドリング

Spring Frameworkの RestTemplate.class を使った通信のエラーハンドリングの仕方についてまとめます。

今回の環境

  • Java 17
  • Spring Boot 2.7.4

試してみた

Client APIからリクエストを受けるAPI(Server)

@RestController
@RequestMapping("/server")
public class ServerController {

    @GetMapping
    public ServerResponse index(@RequestParam(value = "status_code", required = false) Optional<Integer> statusCode) {
        HttpStatus status = statusCode
                .map(HttpStatus::resolve)
                .orElse(null);
        if (status != null && status.isError()) {
            throw new CustomException(status.getReasonPhrase(), status);
        } else {
            return new ServerResponse(1, "name");
        }
    }

    @ExceptionHandler(CustomException.class)
    public ResponseEntity<ErrorResponse> handle(CustomException e) {
        return ResponseEntity
                .status(e.getStatus())
                .body(new ErrorResponse(e.getMessage()));
    }
}
public record ServerResponse(Integer id, String name) {
}
public record ErrorResponse (String message) {
}

リクエストパラメータで指定したステータスコードが400系、500系だったらエラーレスポンスを、それ以外は正常系のレスポンスを返すAPIです。

Server APIにリクエストを投げるAPI(Client)

@RestController
@RequestMapping("/client")
public class ClientController {

    private final ServerService serverService;

    public ClientController(ServerService serverService) {
        this.serverService = serverService;
    }

    @GetMapping("/default")
    public ResponseEntity<ClientResponse> defaultHandler(@RequestParam(value = "status_code", required = false) Optional<Integer> statusCode) throws ServerRestTemplateException {
        ResponseEntity<ServerResponse> serverResponse = serverService.defaultHandlerGet(statusCode);
        return ResponseEntity.status(serverResponse.getStatusCode())
                .body(ClientResponse.newInstance(serverResponse.getBody()));
    }

    @GetMapping("/custom")
    public ResponseEntity<ClientResponse> customHandler(@RequestParam(value = "status_code", required = false) Optional<Integer> statusCode) throws ServerRestTemplateException {
        ResponseEntity<ServerResponse> serverResponse = serverService.customHandlerGet(statusCode);

        if (serverResponse.getStatusCode().isError()) {
            // 4xx, 5xxの場合、ResponseEntityのレスポンスボディは、new ServerResponse(null, null)となる
            throw new ServerRestTemplateException(new ErrorResponse("custom handler error"), serverResponse.getStatusCode());
        }

        return ResponseEntity.status(serverResponse.getStatusCode())
                .body(ClientResponse.newInstance(serverResponse.getBody()));
    }

    @ExceptionHandler(ServerRestTemplateException.class)
    public ResponseEntity<ErrorResponse> handleServerRestTemplateException(ServerRestTemplateException e) {
        return ResponseEntity.status(e.getStatus())
                .body(e.getResponse());
    }

}
@Service
public class ServerService {
    private static final String BASE_URI = "http://localhost:8081/api";
    private static final String BASE_PATH = "/server";

    private final RestTemplate defaultHandlerRestTemplate;
    private final RestTemplate customHandlerRestTemplate;
    private final ObjectMapper objectMapper;

    public ServerService(
            RestTemplateBuilder defaultHandlerRestTemplateBuilder,
            RestTemplateBuilder customHandlerRestTemplateBuilder,
            ObjectMapper objectMapper) {
        this.defaultHandlerRestTemplate = defaultHandlerRestTemplateBuilder.rootUri(BASE_URI).build();
        this.customHandlerRestTemplate = customHandlerRestTemplateBuilder.rootUri(BASE_URI).build();
        this.objectMapper = objectMapper;
    }

    /**
     * RestTemplateを使った通信のエラーハンドリングに {@link org.springframework.web.client.DefaultResponseErrorHandler} を利用
     */
    public ResponseEntity<ServerResponse> defaultHandlerGet(Optional<Integer> statusCode) throws ServerRestTemplateException {
        var requestEntity = buildRequestEntity(statusCode);

        try {
            return defaultHandlerRestTemplate.exchange(requestEntity, ServerResponse.class);
        } catch (HttpStatusCodeException e) {
            try {
                var errorResponse = objectMapper.readValue(e.getResponseBodyAsString(), ErrorResponse.class);
                throw new ServerRestTemplateException(errorResponse, e.getStatusCode());
            } catch (IOException ioException) {
                throw new ServerRestTemplateException("invalid response");
            }
        } catch (RestClientException e) {
            throw new ServerRestTemplateException("error");
        }
    }

    /**
     * RestTemplateを使った通信のエラーハンドリングに {@link com.b1a9idps.client.externals.handler.RestTemplateResponseErrorHandler} を利用
     */
    public ResponseEntity<ServerResponse> customHandlerGet(Optional<Integer> statusCode) {
        var requestEntity = buildRequestEntity(statusCode);

        return customHandlerRestTemplate.exchange(requestEntity, ServerResponse.class);
    }

    private RequestEntity<Void> buildRequestEntity(Optional<Integer> statusCode) {
        var builder = UriComponentsBuilder.fromPath(BASE_PATH);
        if (statusCode.isPresent()) {
            builder.queryParam("status_code", statusCode.get());
        }
        var uri = builder.toUriString();

        return RequestEntity.get(uri).build();
    }
}

実行

Server API、Client APIともに起動し、Server APIにリクエストを投げてみます。

GET /api/client/default がDefaultResponseErrorHandlerでエラーハンドリングするエンドポイントで、 GET /api/client/custom はResponseErrorHandlerを実装したクラスでエラーハンドリングするエンドポイントです。

% curl 'http://localhost:8080/api/client/default' | jq .
{
  "id": 1,
  "name": "name"
}

% curl 'http://localhost:8080/api/client/custom' | jq .
{
  "id": 1,
  "name": "name"
}

% curl 'http://localhost:8080/api/client/default?status_code=403' | jq .
{
  "message": "Forbidden"
}

% curl 'http://localhost:8080/api/client/custom?status_code=403' | jq .
{
  "message": "custom handler error"
}

解説

それぞれのエラーハンドリングについて解説します。

DefaultResponseErrorHandlerでエラーハンドリング

デフォルトだと、RestTemplateで通信して起きたエラー(ステータスコード400系か500系)は、DefaultResponseErrorHandlerでハンドリングされます。

該当コードを見てみるとこのように実装されており、通信してステータスコード400系と500系が返ってくるとHttpClientErrorExceptionを継承した例外クラスが投げられます。

@Override
public void handleError(ClientHttpResponse response) throws IOException {
    HttpStatus statusCode = HttpStatus.resolve(response.getRawStatusCode());
    if (statusCode == null) {
        byte[] body = getResponseBody(response);
        String message = getErrorMessage(response.getRawStatusCode(),
                response.getStatusText(), body, getCharset(response));
        throw new UnknownHttpStatusCodeException(message,
                response.getRawStatusCode(), response.getStatusText(),
                response.getHeaders(), body, getCharset(response));
    }
    handleError(response, statusCode);
}

protected void handleError(ClientHttpResponse response, HttpStatus statusCode) throws IOException {
    String statusText = response.getStatusText();
    HttpHeaders headers = response.getHeaders();
    byte[] body = getResponseBody(response);
    Charset charset = getCharset(response);
    String message = getErrorMessage(statusCode.value(), statusText, body, charset);

    switch (statusCode.series()) {
        case CLIENT_ERROR:
            throw HttpClientErrorException.create(message, statusCode, statusText, headers, body, charset);
        case SERVER_ERROR:
            throw HttpServerErrorException.create(message, statusCode, statusText, headers, body, charset);
     default:
            throw new UnknownHttpStatusCodeException(message, statusCode.value(), statusText, headers, body, charset);
    }
}

なので、HttpStatusCodeExceptionをcatchして、エラー時のレスポンスをHttpStatusCodeException#getResponseBodyAsStringで取得しています。

public ResponseEntity<ServerResponse> defaultHandlerGet(Optional<Integer> statusCode) throws ServerRestTemplateException {
    var requestEntity = buildRequestEntity(statusCode);

    try {
        return defaultHandlerRestTemplate.exchange(requestEntity, ServerResponse.class);
    } catch (HttpStatusCodeException e) {
        try {
            var errorResponse = objectMapper.readValue(e.getResponseBodyAsString(), ErrorResponse.class);
            throw new ServerRestTemplateException(errorResponse, e.getStatusCode());
        } catch (IOException ioException) {
            throw new ServerRestTemplateException("invalid response");
        }
    } catch (RestClientException e) {
        throw new ServerRestTemplateException("error");
    }
}

ResponseErrorHandlerを実装したクラスでエラーハンドリング

RestTemplateで通信してエラーが発生した際に例外を投げてほしくないという場合には、ResponseErrorHandlerを実装したクラスを用意します。 この例では、ステータスコード400系と500系が返ってきても何もしないように実装しています。

public class RestTemplateResponseErrorHandler extends DefaultResponseErrorHandler {
    @Override
    public void handleError(ClientHttpResponse response) throws IOException {
    }
}

そして、RestTemplateでこのErrorHandlerを利用するようにします。

@Configuration(proxyBeanMethods = false)
public class RestTemplateConfig {
    @Bean
    public RestTemplateBuilder defaultHandlerRestTemplateBuilder() {
        return new RestTemplateBuilder();
    }

    @Bean
    public RestTemplateBuilder customHandlerRestTemplateBuilder() {
        return new RestTemplateBuilder()
                .errorHandler(new RestTemplateResponseErrorHandler());
    }
}

先に説明したように、ステータスコード400系や500系が返ってきても何もしないので、 new ServerResponse(null, null) がレスポンスボディとして返されます。

public ResponseEntity<ServerResponse> customHandlerGet(Optional<Integer> statusCode) {
    var requestEntity = buildRequestEntity(statusCode);

    return customHandlerRestTemplate.exchange(requestEntity, ServerResponse.class);
}

レスポンスボディからはエラーかどうかを判断できないので、ステータスコードでエラーかどうかを判断しています。

GetMapping("/custom")
public ResponseEntity<ClientResponse> customHandler(@RequestParam(value = "status_code", required = false) Optional<Integer> statusCode) throws ServerRestTemplateException {
    ResponseEntity<ServerResponse> serverResponse = serverService.customHandlerGet(statusCode);

    if (serverResponse.getStatusCode().isError()) {
        // 4xx, 5xxの場合、ResponseEntityのレスポンスボディは、new ServerResponse(null, null)となる
        throw new ServerRestTemplateException(new ErrorResponse("custom handler error"), serverResponse.getStatusCode());
    }

    return ResponseEntity.status(serverResponse.getStatusCode())
            .body(ClientResponse.newInstance(serverResponse.getBody()));
}

@ExceptionHandler(ServerRestTemplateException.class)
public ResponseEntity<ErrorResponse> handleServerRestTemplateException(ServerRestTemplateException e) {
    return ResponseEntity.status(e.getStatus())
            .body(e.getResponse());
}

まとめ

ResponseErrorHandlerを実装したクラスでエラーハンドリングを行うと、例外処理を書かなくて良くなるなる反面、呼び出したAPIからのエラーレスポンスが失われるかなと思います。ステータスコードさえわかって呼び出す側のシステムでエラーレスポンスを完全に決める場合なら有効かなと思います。

DefaultResponseErrorHandlerでエラーハンドリングする場合は、例外処理を書くのが億劫ですが呼び出したAPIからのエラーレスポンスを返すことができます。(あまり戻ってきたエラーレスポンスをそのまま返すというケースはないかもしれないですが)

Links