LoginSignup
2
2

More than 3 years have passed since last update.

java.net.http.HttpClient でHTTPレスポンスを受信するとリクエストがタイムアウトした

Last updated at Posted at 2021-01-24

事象に遭遇したのは AdoptOpenJDK jdk-11.0.9.1+1。

事象発生コード

数年ぶりにJavaで開発を行うことになり、外部APIをHTTPリクエストで呼び出してレスポンスのJSONを受信する必要が出てきた。

最後にJavaで開発した時はJDK 8でHTTPリクエストを行う標準ライブラリは
java.net.HttpURLConnectionくらいしかなかったが、
JDK 11からはjava.net.http.HttpClientが正式に標準ライブラリ入りしていたらしいので使用することにした。

JSONからのデシリアライズはJacksonを使おうと考えたが、HttpClientはレスポンスボディをreactive-streamsで処理しているらしい。

最も簡単な取り扱い方だとHttpResponse.BodyHandlers#ofString()で一度レスポンスボディをStringにしてからパースする方法だが、
せっかくのreactive-streamsなのでいい感じにデータ受信完了後にJSONを処理できる方法が無いか調べると
java.net.http.HttpResponse.BodySubscribers#mapping​(HttpResponse.BodySubscriber<T>, Function<? super T,​? extends U>)のjavadocが見つかった。

javadocの中でJacksonを利用してレスポンスボディを直接デシリアライズしてJavaオブジェクトを返すサンプルコードが書かれていたので、
そこで紹介されているコードをほぼそのまま流用させてもらうことにした。

実装は大体以下のようなコードになった。

public static <T> BodySubscriber<T> jsonSubscriber(
    ObjectMapper objectMapper,
    Class<T> klazz) {

    Objects.requireNonNull(objectMapper);
    Objects.requireNonNull(klazz);

    return BodySubscribers.mapping(
          BodySubscribers.ofInputStream(),
          (InputStream bodyStream) -> {
              try (bodyStream) {
                  return objectMapper.readValue(bodyStream, klazz);
              } catch (IOException e) {
                  throw new UncheckedIOException(e);
              }
          });
}

上記のメソッドで生成したサブスクライバを
java.net.http.HttpResponse.BodyHandler<T>の実装クラスで返すことで
HttpClientでレスポンスを受信した時にデシリアライズが実施されるようにしていた。

public class FooResponseHandler implements HttpResponse.BodyHandler<Foo> {

    private final HttpResponse.BodySubscriber<Foo> subscriber;

    public FooResponseHandler(ObjectMapper objectMapper) {
        this.subscriber = jsonSubscriber(objectMapper, Foo.class);
    }

    @Override
    public HttpResponse.BodySubscriber<Foo> apply(HttpResponse.ResponseInfo responseInfo) {
        if (responseInfo.statusCode() == 200) {
            return this.subscriber;
        }

        throw new RuntimeException("Unknown response");
    }
}

発生した問題

HttpClientの動作確認のために外部APIを呼び出しすると、なぜか数十秒経過後にタイムアウトが発生する。

TCPダンプを見るとAPIからのレスポンスは1秒かからず返ってきており、レスポンスボディも想定された内容だった。

しかし、HttpClientはタイムアウトが発生する。

デバッガーで原因個所を特定していくと、BodySubscribers.mapping()の第2引数に渡された
マッピング関数が実行された後でスレッドがフリーズしているらしいことが分かった。

スレッドはフリーズしていたが、HTTPリクエストタイムアウトを発生させるスレッドは動いていたようで、
そのスレッドから例外が通知されるまでの数十秒間が待ち時間になっていたようだ。

原因と対処

このあたりでJDKの不具合を疑い調べてみるとStack Overflowなどに情報をまとめてくれている方たちがいた。

HttpClientは内部でI/O操作のためのExecutorCompletableFutureを実行しているが、
HTTPレスポンスボディを処理する中でブロッキングAPIが呼び出しされるとExecutorのスレッドが枯渇してしまうことがあるらしい。

具体的にどこでスレッドが枯渇しているのかは追いかけていないが、InputStreamからの読み取りでフリーズしているように見えたので、
レスポンスボディのバイト列をInputStreamに追加する処理と
InputStreamから読み取ってJSONデシリアライズする処理で競合のような状態が発生したと予想している。

対処方法は、紹介されているとおりBodySubscriber<T>BodySubscriber<Supplier<T>>に変更するしかないと思われる。

HttpClient内部処理が完了した後でレスポンスボディにアクセスする様になるため、スレッドの問題が回避できる。

ただ、この方法だとHTTPレスポンスボディのデータが全てInputStreamにバッファリングされていそうなので、
JSONデシリアライズみたいな目的の時はHttpResponse.BodyHandlers#ofString()を使用するのとメモリ効率などがあまり変わらない気がするのが気になる。

この不具合はJDK 13で修正されているようだが、同時にjavadocも更新されており
BodySubscriberないぶでレスポンスボディのInputStreamなどにアクセスする場合は
BodySubscriber<Supplier<T>>を使用する方法が推奨されるようになったようだ。

2
2
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
2
2