LoginSignup
3
2

More than 3 years have passed since last update.

HttpUrlConnectionの謎いスタックトレース

Last updated at Posted at 2020-02-05

概要

HttpUrlConnectiongetInputStreamメソッド呼び出し時にIOExceptionが発生した場合、意味がわからない例外スタックトレースが返却されることがあります。

TL;DR

HttpUrlConnection#getInputStreamIOExceptionが発生する場合、過去にそのインスタンスで発生したIOExceptionがネストされます。

検証環境

  • macOS 10.15.2
  • AdoptOpenJDK (HotSpot) 1.8.0_242-b08

事象

以下のようなコードがあるとします。

  • 400 Bad Requestを決め打ちで返却する簡易HTTPサーバを用意する
  • HttpUrLConnectionを使って、簡易HTTPサーバにリクエストを送信する
  • HttpUrLConnection#getInputStreamを呼び出しレスポンスの読み取りを行う

コード

import com.sun.net.httpserver.Headers;
import com.sun.net.httpserver.HttpServer;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.URL;

public class HttpURLConnectionTest {

    public static void main(String[] args) throws IOException {
        // HTTP Server
        HttpServer server = HttpServer.create(new InetSocketAddress(8087), 0);
        server.createContext("/", exchange -> {
            Headers headers = exchange.getResponseHeaders();
            headers.add("Content-type", "text/plain");
            String body = "error!";
            byte[] bytes = body.getBytes();
            exchange.sendResponseHeaders(400, bytes.length);
            try (OutputStream out = exchange.getResponseBody()) {
                out.write(bytes);
            }
        });
        server.start();
        System.out.println("start server.");

        // HTTP Client
        HttpURLConnection conn = null;
        try {
            URL url = new URL("http://localhost:8087/");
            conn = (HttpURLConnection) url.openConnection();
            conn.connect();
            int responseCode = conn.getResponseCode();
            System.out.println("responseCode = " + responseCode); // 400

            try (InputStream in = conn.getInputStream()) {  // ここで例外発生
                throw new AssertionError("ここにはこないはず");
            }
        } catch (IOException e) {
            e.printStackTrace();  // よくわからないスタックトレースが表示される
        } finally {
            if (conn != null) conn.disconnect();
            server.stop(0);
        }
    }
}

実行すると、以下のようなスタックトレースが表示されます。

スタックトレース

コンソールには以下のように表示されます。

start server.
responseCode = 400
java.io.IOException: Server returned HTTP response code: 400 for URL: http://localhost:8087/
    at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
    at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
    at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
    at java.lang.reflect.Constructor.newInstance(Constructor.java:423)
    at sun.net.www.protocol.http.HttpURLConnection$10.run(HttpURLConnection.java:1950)
    at sun.net.www.protocol.http.HttpURLConnection$10.run(HttpURLConnection.java:1945)
    at java.security.AccessController.doPrivileged(Native Method)
    at sun.net.www.protocol.http.HttpURLConnection.getChainedException(HttpURLConnection.java:1944)
    at sun.net.www.protocol.http.HttpURLConnection.getInputStream0(HttpURLConnection.java:1514)
    at sun.net.www.protocol.http.HttpURLConnection.getInputStream(HttpURLConnection.java:1498)
    at HttpURLConnectionTest.main(HttpURLConnectionTest.java:38)
Caused by: java.io.IOException: Server returned HTTP response code: 400 for URL: http://localhost:8087/
    at sun.net.www.protocol.http.HttpURLConnection.getInputStream0(HttpURLConnection.java:1900)
    at sun.net.www.protocol.http.HttpURLConnection.getInputStream(HttpURLConnection.java:1498)
    at java.net.HttpURLConnection.getResponseCode(HttpURLConnection.java:480)
    at HttpURLConnectionTest.main(HttpURLConnectionTest.java:35)

Process finished with exit code 0

考察

前提

HttpURLConnectionは、レスポンスのステータスコードが400以上の時にgetInputStreamが呼び出された場合、IOExceptionまたはFileNotFoundExceptionをスローします。

                if (respCode >= 400) {
                    if (respCode == 404 || respCode == 410) {
                        throw new FileNotFoundException(url.toString());
                    } else {
                        throw new java.io.IOException("Server returned HTTP" +
                              " response code: " + respCode + " for URL: " +
                              url.toString());
                    }
                }

この場合、getInputStreamではなくgetErrorStreamメソッドを使用しなければ、レスポンスボディが読めません。

にわかに信じ難いAPIですが、実際そのように動作します。

スタックトレースの考察

本題であるスタックトレースを見てみますと、ネストされた例外が存在しています。

最初の例外を見ると、getInputStreamの呼び出しでIOExceptionが発生しており、上記前提事項と整合性が取れるスタックトレースになっています。

問題はネストした例外のほうです。

ネストした例外を見ると、getInputStreamではなく、その前に呼び出したgetResponseCodeの呼び出しで例外が発生しているようにみえます。しかし、実際はgetResponseCodeは例外をスローせず、ステータスコード400を戻り値として返却しています。

getInputStreamより2行前に実行したメソッド呼び出しのスタックトレースが、その後getInputStreamにネストされており、通常のJavaプログラミングではお目にかかれないようなスタックトレースになっています。

なぜこのようなスタックトレースが発生するのでしょうか?

getResponseCode呼び出し時の動作

  • getResponseCodeは内部でgetInputStreamを呼び出す(ステータスコードを読み取るため)
  • ステータスコード400以上のとき、getInputStreamIOExceptionをスローする
    • その際、発生した例外をインスタンスフィールドに保存しておく
  • getResponseCodeは発生したIOExceptionはスローせず、ステータスを戻り値として返却する

その後のgetInputStream呼び出し時の動作

  • getInputStreamIOException発生時、以前に発生した例外がある場合は、今回の例外にネストさせる

ソースコード上はこのように記載されています。

    /* Remembered Exception, we will throw it again if somebody
       calls getInputStream after disconnect */
    private Exception rememberedException = null;

disconnectしたあとにgetInputStreamが呼ばれた場合、例外を再度スローするために記憶しておく」とあります。

以前に例外が発生した場合、新しい例外に以前の例外をネストさせます(Throwable#initCauseを使用する)。

    private synchronized InputStream getInputStream0() throws IOException {
        // 中略

        if (rememberedException != null) {
            if (rememberedException instanceof RuntimeException)
                throw new RuntimeException(rememberedException);
            else {
                throw getChainedException((IOException)rememberedException);
            }
        }
        try {
            final Object[] args = { rememberedException.getMessage() };
            IOException chainedException = //...

            // 中略

            // ここで以前の例外をネストさせる
            chainedException.initCause(rememberedException);
            return chainedException;
        } catch (Exception ignored) {
            return rememberedException;
        }
    }

このため、以前getResponseCodeが起動されたときに内部で発生していたIOExceptionが保存され、その後にgetInputStreamを起動した際にネストした例外として現れる、という仕組みです。

API使用者からするとgetResponseCodeは成功したように見えますので、その後のgetInputStreamでネストした例外に、前回正常終了したgetResponseCodeのスタックトレースが含まれるのは意味不明に思われます。

私はgetInputStreamgetErrorStreamを使いわける必要があることを知らなかったため、
このスタックトレースを見た時、全く意味がわかりませんでした。

まとめ

HttpUrlConnection#getInputStreamIOExceptionが発生する場合、過去にそのインスタンスで発生したIOExceptionがネストされます。

今回の調査を通して、HttpUrlConnectionには以下のような問題点があると考えました。

  1. ステータスコードによって、getInputStreamgetErrorStreamを使いわけを強いる不自然なAPI設計がなされている
  2. API使用者に露呈していない例外を、別の例外にネストさせるような不自然な挙動をする
  3. APIドキュメントに、そのような振る舞いが明記されていない(実装依存)
  4. 上記の問題点が改善される気配がない

1のAPI設計については、いろいろな人が疑義を呈しています。

またJava11, 13でも同じ動作となるところを見ると、互換性維持のため挙動を変えれないのだと推察されます。それはそれで仕方ないのですが、せめてAPIドキュメントだけでも改善してほしいと思います。

お世辞にも使いやすいAPIとは言えないので、どうしても標準APIを使う必要がある等、特別な理由がない限りは別のライブラリを使用するのがよさそうです。

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