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からのエラーレスポンスを返すことができます。(あまり戻ってきたエラーレスポンスをそのまま返すというケースはないかもしれないですが)