概要
HttpUrlConnectionの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を呼び出しレスポンスの読み取りを行う
コード
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以上のとき、
getInputStream
はIOException
をスローする- その際、発生した例外をインスタンスフィールドに保存しておく
-
getResponseCode
は発生したIOException
はスローせず、ステータスを戻り値として返却する
その後のgetInputStream呼び出し時の動作
-
getInputStream
でIOException
発生時、以前に発生した例外がある場合は、今回の例外にネストさせる
ソースコード上はこのように記載されています。
/* 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
のスタックトレースが含まれるのは意味不明に思われます。
私はgetInputStream
とgetErrorStream
を使いわける必要があることを知らなかったため、
このスタックトレースを見た時、全く意味がわかりませんでした。
まとめ
HttpUrlConnection#getInputStream
でIOException
が発生する場合、過去にそのインスタンスで発生したIOException
がネストされます。
今回の調査を通して、HttpUrlConnectionには以下のような問題点があると考えました。
- ステータスコードによって、
getInputStream
とgetErrorStream
を使いわけを強いる不自然なAPI設計がなされている - API使用者に露呈していない例外を、別の例外にネストさせるような不自然な挙動をする
- APIドキュメントに、そのような振る舞いが明記されていない(実装依存)
- 上記の問題点が改善される気配がない
1のAPI設計については、いろいろな人が疑義を呈しています。
- http://quesera2.hatenablog.jp/entry/2014/12/08/001700
- https://qiita.com/KeithYokoma/items/4b72096d386e919379e8
またJava11, 13でも同じ動作となるところを見ると、互換性維持のため挙動を変えれないのだと推察されます。それはそれで仕方ないのですが、せめてAPIドキュメントだけでも改善してほしいと思います。
お世辞にも使いやすいAPIとは言えないので、どうしても標準APIを使う必要がある等、特別な理由がない限りは別のライブラリを使用するのがよさそうです。