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



# 概要

[HttpUrlConnection](https://docs.oracle.com/javase/jp/8/docs/api/java/net/HttpURLConnection.html)の`getInputStream`メソッド呼び出し時に`IOException`が発生した場合、意味がわからない例外スタックトレースが返却されることがあります。

## TL;DR

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

## 検証環境

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

# 事象

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

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


## コード

``` java
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);
        }
    }
}
```

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

## スタックトレース

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

``` txt
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`をスローします。

``` java
                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以上のとき、`getInputStream`は`IOException`をスローする
  - その際、発生した例外をインスタンスフィールドに保存しておく
- `getResponseCode`は発生した`IOException`はスローせず、ステータスを戻り値として返却する

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


- `getInputStream`で`IOException`発生時、以前に発生した例外がある場合は、今回の例外にネストさせる


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

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

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

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

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

        if (rememberedException != null) {
            if (rememberedException instanceof RuntimeException)
                throw new RuntimeException(rememberedException);
            else {
                throw getChainedException((IOException)rememberedException);
            }
        }
```

``` java
        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`のスタックトレースが含まれるのは意味不明に思われます。

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

# まとめ


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

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

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



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

- http://quesera2.hatenablog.jp/entry/2014/12/08/001700
- https://qiita.com/KeithYokoma/items/4b72096d386e919379e8

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

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