LoginSignup
9
0

More than 1 year has passed since last update.

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

Posted at

この記事はラクスアドベントカレンダーの3日目の記事です。

本来なら月末に今年やったことを振り返ってゆっくり書きたかったのですが、前半がぽっかり空いていたので愛社&奉仕精神に溢れる私はみんなが嫌がる12/3に名乗りを上げました!(明日と明後日も空いてるがね( ゚Д゚))

はじめに

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

使い始めたきっかけ

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

OkHttp, Retrofitとは

OkHttpはHTTP通信に特化した軽量なライブラリでAndroidでよく使われているみたいです。

RetrofitはOkHttpをラップしてより便利に使えるようにしたライブラリです。
開発元はどちらもSquareです。

使い方

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

Hello World

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

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

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のクラスを呼ぶような感覚で呼び出すことができます。

// 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形式でエラーを返す仕様だったのですがその値を受け取るのにハマりました。

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

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

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

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でした。

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

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

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を読んでやらー、ってことでやってみます。

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という中身を読めないクラスに置き換えていました( ゚Д゚)

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でレスポンスボディをログに出力してみます。

// レスポンスボディをログに出す指定
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を参考に自作してみました。

@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に変更します。

// 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)

おわりに

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

9
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
0