Retrofit+OkHttpでAPIエラーの時のレスポンスの取得ではまった

### はじめに
最近機能開発でRetrofitというものを使い始めていて非常に便利なライブラリなのですが、ハマりポイントもあったのでその辺の話を書いていこうと思います。

### 使い始めたきっかけ
Spring Bootで開発してるのでRestTemplateを使おうとしたのですが、将来のバージョンで非推奨になるとのことで別のライブラリを比較検討しました。
RestTemplateの後継はWebClientなんですが、そのためだけにSpring WebFluxは入れたくない...そこで更に調べてOkHttp + Retrofitが良さそうということで採用しました。

### OkHttp, Retrofitとは
OkHttpはHTTP通信に特化した軽量なライブラリでAndroidでよく使われているみたいです。  
RetrofitはOkHttpをラップしてより便利に使えるようにしたライブラリです。
開発元はどちらも[Square](https://square.github.io/)です。

### 使い方
まずRetrofitの使い方は[公式サイト](https://square.github.io/retrofit/)を見れば雰囲気分かると思いますが、本格的に使うにはOkHttpのクラスを直接使う部分が出てきてしまうので[OkHttpの公式サイト](https://square.github.io/okhttp/)も参照しましょう。
調べると古い情報が多いので公式サイトを見た方が良いと思います。
（ちなみに、この記事はretrofit:2.9.0, okHttp:3.14.9をベースに書かれています）


### Hello World
本題に入る前にどんな感じで使うのかシンプルな例を挙げておきます。

まずinterfaceを作成して、APIのエンドポイントごとにメソッドを作ります。

```java
interface UsersApiService {
  @GET("{tenantId}/users")
  Call<ResponseData> userList(@Path("tenantId") int tenantId, @Query("sort") String sort);
}

// Responseを詰めるクラスも作っておきます
record ResponseData(String status, Integer code, List<User> userList){};
record User(String name, String mailAddress){};
```

上記の例だとGETメソッドで`xxx/users?sort=xxx`を呼び出してレスポンスボディのJSONを`ResponseData`に詰めるところまでを一気にやってくれる定義です。
あとは、下記のように呼び出します。UserApiServiceのメソッド呼び出しは普通のJavaのクラスを呼ぶような感覚で呼び出すことができます。

```java
// Retrofitのインスタンスを生成
Retrofit retorofit = new Retrofit.Builder()
        .baseUrl("http://localhost:8080")
        // JSONを変換するコンバーターを指定（今回はJacksonを使用）
        .addConverterFactory(JacksonConverterFactory.create())
        .build();
// 先ほど作成したinterfaceからインスタンスを生成
UsersApiService service = retorofit.create(UsersApiService.class);
// API実行！
ResponseData data = service.userList(1, "asc").execute().body();
```

### ハマったところ
ここからが本題ですが、今回呼び出したAPIはパラメータが不正な場合HTTPステータス=400でJSON形式でエラーを返す仕様だったのですがその値を受け取るのにハマりました。

```JSON
# 正常時のレスポンス
{
  "status": "success",
  "code": 200,
  [{"name": "Taro", "mailAddress": "taro@example.com"}]
}

# 異常時のレスポンス
{
  "status": "error",
  "code": 400,
  "error": "パラメータsortの入力値が間違っています"
}
```

では、対処していきます。
今回のケースはエラー内容はログに出して見れれば良くてJSONにパースする必要はないのですが、何でも受け取れるResponseDataAnyクラスを作って実験してみました。

```java
class ResponseDataAny {
    Map<String, Object> any = new HashMap<>();
    @JsonAnySetter
    public void setAny(String key, Object value) {
        this.any.put(key, value);
    }
    @JsonAnyGetter
    public Map<String, Object> getAny() {
        return this.any;
    }
}
```

実行したところ、正常時は値が取れますが異常時の取得結果はnullでした。

```java
ResponseDataAny data = service.userList(1, "asc").execute().body();
```

良く分からないのでOkHttpのコードを読んでいくとレスポンスコードが200番台以外はJSONへのパースを放棄してreturnしてました...Oh..

```java:OkHttpCall.java
int code = rawResponse.code();
if (code < 200 || code >= 300) {
  try {
    // Buffer the entire body to avoid future I/O.
    ResponseBody bufferedBody = Utils.buffer(rawBody);
    return Response.error(bufferedBody, rawResponse);
  } finally {
    rawBody.close();
  }
}
```

### 次に、自分でResponseを読んでみることに
 
そっちがその気なら直接Responseを読んでやらー、ってことでやってみます。

```java
Response response = service.userList(1, "asc").execute();

// response.raw()でokhttp3.Responseを取り出してゴニョゴニョしてみる
ResponseBody responseBody = response.raw().body();
BufferedSource source = responseBody.source();
```

ダメでした。生のレスポンスボディは読めないと怒られてしまいます。

```
Caused by: java.lang.IllegalStateException: Cannot read raw response body of a converted body.
```

またOkHttpCallクラスの内部実装を見ていくと、JSONにパースするあたりで`NoContentResponseBody`という中身を読めないクラスに置き換えていました( ﾟДﾟ)

```java:OkHttpCall.java
Response<T> parseResponse(okhttp3.Response rawResponse) throws IOException {
  ResponseBody rawBody = rawResponse.body();

  // Remove the body's source (the only stateful object) so we can pass the response along.
  rawResponse =
      rawResponse
          .newBuilder()
          .body(new NoContentResponseBody(rawBody.contentType(), rawBody.contentLength()))
          .build();
```

### HttpLoggingInterceptorでログ出力してみる

ダメでしたので別のアプローチとして、HttpLoggingInterceptorでレスポンスボディをログに出力してみます。

```java
// レスポンスボディをログに出す指定
HttpLoggingInterceptor loggingInterceptor = new HttpLoggingInterceptor();
loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY);

// OkHttpClientを生成する
OkHttpClient okHttpClient = new OkHttpClient.Builder()
            .addInterceptor(loggingInterceptor)
            .build();
// Retrofitに生成したOkHttpClientをセットする
Retrofit retorofit = new Retrofit.Builder()
            .client(okHttpClient)
            .baseUrl("http://localhost:8080")
            // JSONを変換するコンバーターを指定（今回はJacksonを使用）
            .addConverterFactory(JacksonConverterFactory.create())
            .build();
```

エラーでもボディの内容がログ出力されました。

```
2021-12-04 00:09:05.277  INFO 35482 --- [nio-8080-exec-1] okhttp3.OkHttpClient                     : --> GET http://localhost:8080/1/users
2021-12-04 00:09:05.277  INFO 35482 --- [nio-8080-exec-1] okhttp3.OkHttpClient                     : --> END GET
2021-12-04 00:09:05.301  INFO 35482 --- [nio-8080-exec-1] okhttp3.OkHttpClient                     : <-- 400 http://localhost:8080/1/users (23ms)
2021-12-04 00:09:05.301  INFO 35482 --- [nio-8080-exec-1] okhttp3.OkHttpClient                     : Content-Type: application/json
2021-12-04 00:09:05.301  INFO 35482 --- [nio-8080-exec-1] okhttp3.OkHttpClient                     : Transfer-Encoding: chunked
2021-12-04 00:09:05.301  INFO 35482 --- [nio-8080-exec-1] okhttp3.OkHttpClient                     : Date: Fri, 03 Dec 2021 15:09:05 GMT
2021-12-04 00:09:05.301  INFO 35482 --- [nio-8080-exec-1] okhttp3.OkHttpClient                     : Connection: close
2021-12-04 00:09:05.302  INFO 35482 --- [nio-8080-exec-1] okhttp3.OkHttpClient                     : 
2021-12-04 00:09:05.302  INFO 35482 --- [nio-8080-exec-1] okhttp3.OkHttpClient                     : {"code":400,"error":"パラメータsortの入力値が間違っています","status":"error"}
2021-12-04 00:09:05.302  INFO 35482 --- [nio-8080-exec-1] okhttp3.OkHttpClient                     : <-- END HTTP (95-byte body)
```

ただ、セキュリティ観点で正常時はログに出したくないのですが、エラー時だけ出すような制御はHttpLoggingInterceptorではできないので本番投入は無理でした。

### インターセプターを自作する

そこでエラー時だけログに出力するインターセプターをHttpLoggingInterceptorを参考に自作してみました。

```java
@Slf4j
public class ErrorLogInterceptor implements Interceptor {

  @Override
  public Response intercept(Chain chain) throws IOException {
    // API実行
    Response response = chain.proceed(chain.request());
    // 正常終了時、またはボディがない場合は何もせず処理を終了する
    if (response.isSuccessful() || !HttpHeaders.hasBody(response)) {
      return response;
    }

    // 生のResponseBodyを取得する
    BufferedSource source = Objects.requireNonNull(response.body()).source();
    source.request(Long.MAX_VALUE);
    Buffer buffer = source.getBuffer();
    String str = buffer.clone().readString(StandardCharsets.UTF_8);
    logger.log("Body Content: " + str);

    return response;
  }

}

```

作成したインターセプターをセットします。またHttpLoggingInterceptorの設定はLevel.BASICに変更します。

```java
// HttpLoggingInterceptorのLevelはBODYからBASICに変更する
loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BASIC);

OkHttpClient okHttpClient = new OkHttpClient.Builder()
           .addInterceptor(new ErrorLogInterceptor()) //追加する
           .addInterceptor(loggingInterceptor)
           .build();
```

実行するとちゃんとエラー時にレスポンス内容がログ出力されました。

```
2021-12-04 00:17:27.466  INFO 35513 --- [nio-8080-exec-1] okhttp3.OkHttpClient                     : --> GET http://localhost:8080/1/users
2021-12-04 00:17:27.491  INFO 35513 --- [nio-8080-exec-1] okhttp3.OkHttpClient                     : <-- 400 http://localhost:8080/1/users (24ms, unknown-length body)
2021-12-04 00:17:27.491  INFO 35513 --- [nio-8080-exec-1] c.e.jooq.controller.ErrorLogInterceptor  : Body Content: {"code":400,"error":"パラメータsortの入力値が間違っています","status":"error"}
```

逆に正常時には想定通りログ出力されていませんので想定した結果になりました。

```
2021-12-04 00:18:52.264  INFO 35533 --- [-192.168.50.217] o.s.web.servlet.DispatcherServlet        : Completed initialization in 0 ms
2021-12-04 00:18:57.852  INFO 35533 --- [nio-8080-exec-1] okhttp3.OkHttpClient                     : --> GET http://localhost:8080/1/users?sort=asc
2021-12-04 00:18:57.879  INFO 35533 --- [nio-8080-exec-1] okhttp3.OkHttpClient                     : <-- 200 http://localhost:8080/1/users?sort=asc (26ms, unknown-length body)
```

### おわりに

取っ掛かりは簡単でしたが、結構ハマりました。
本当はもっと楽にできる方法がありそうな気がしますが、出来たのでとりあえず満足です。