Java11からSSL通信のエラーが発生しやすくなった事への考察と対処

# 2020/03/13 追記
まだ結構みてくれているので、対応状況を追記します。

https://github.com/apache/httpcomponents-client/pull/178
上記で、対応されています。
https://github.com/apache/httpcomponents-client/blob/master/httpclient5/src/main/java/org/apache/hc/client5/http/impl/DefaultHttpRequestRetryStrategy.java#L123-L135

5.0を使えば解決はしそうです。まだRC版しか提供はされていません。

# はじめに

Java8+Spring Boot 1.5.Xの頃は、99.9999999%以上で通信が成功していたのに、Java11 + Spring Boot 2.1.Xにしてから、通信エラーの発生率が0.002%くらい上がったぞ・・・？と思い、調べてみました。

もしかしたら、間違っているかもしれないので、色々指摘を貰えると本当に嬉しいです。助かります。

# 発生するエラー

```java
javax.net.ssl.SSLException: Connection reset
    at java.base/sun.security.ssl.Alert.createSSLException(Alert.java:127)
    at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:321)
    at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:264)
    at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:259)
    at java.base/sun.security.ssl.SSLSocketImpl.handleException(SSLSocketImpl.java:1314)
    at java.base/sun.security.ssl.SSLSocketImpl$AppInputStream.read(SSLSocketImpl.java:839)
    at org.apache.http.impl.io.SessionInputBufferImpl.streamRead(SessionInputBufferImpl.java:137)
    at org.apache.http.impl.io.SessionInputBufferImpl.fillBuffer(SessionInputBufferImpl.java:153)
    at org.apache.http.impl.io.SessionInputBufferImpl.readLine(SessionInputBufferImpl.java:280)
    at org.apache.http.impl.conn.DefaultHttpResponseParser.parseHead(DefaultHttpResponseParser.java:138)
    at org.apache.http.impl.conn.DefaultHttpResponseParser.parseHead(DefaultHttpResponseParser.java:56)
    at org.apache.http.impl.io.AbstractMessageParser.parse(AbstractMessageParser.java:259)
    at org.apache.http.impl.DefaultBHttpClientConnection.receiveResponseHeader(DefaultBHttpClientConnection.java:163)
    at org.apache.http.impl.conn.CPoolProxy.receiveResponseHeader(CPoolProxy.java:157)
    at org.apache.http.protocol.HttpRequestExecutor.doReceiveResponse(HttpRequestExecutor.java:273)
    at org.apache.http.protocol.HttpRequestExecutor.execute(HttpRequestExecutor.java:125)
    at org.apache.http.impl.execchain.MainClientExec.execute(MainClientExec.java:272)
    at org.apache.http.impl.execchain.ProtocolExec.execute(ProtocolExec.java:186)
    at org.apache.http.impl.execchain.RetryExec.execute(RetryExec.java:89)
    at org.apache.http.impl.execchain.RedirectExec.execute(RedirectExec.java:110)
    at org.apache.http.impl.client.InternalHttpClient.doExecute(InternalHttpClient.java:185)
    at org.apache.http.impl.client.CloseableHttpClient.execute(CloseableHttpClient.java:83)
    at org.apache.http.impl.client.CloseableHttpClient.execute(CloseableHttpClient.java:56)
    at org.springframework.http.client.HttpComponentsClientHttpRequest.executeInternal(HttpComponentsClientHttpRequest.java:87)
    at org.springframework.http.client.AbstractBufferingClientHttpRequest.executeInternal(AbstractBufferingClientHttpRequest.java:48)
    at org.springframework.http.client.AbstractClientHttpRequest.execute(AbstractClientHttpRequest.java:53)
    at org.springframework.web.client.RestTemplate.doExecute(RestTemplate.java:735)
    ... 81 common frames omitted
    Suppressed: java.net.SocketException: Broken pipe (Write failed)
        at java.base/java.net.SocketOutputStream.socketWrite0(Native Method)
        at java.base/java.net.SocketOutputStream.socketWrite(SocketOutputStream.java:110)
        at java.base/java.net.SocketOutputStream.write(SocketOutputStream.java:150)
        at java.base/sun.security.ssl.SSLSocketOutputRecord.encodeAlert(SSLSocketOutputRecord.java:81)
        at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:352)
        ... 106 common frames omitted
Caused by: java.net.SocketException: Connection reset
    at java.base/java.net.SocketInputStream.read(SocketInputStream.java:186)
    at java.base/java.net.SocketInputStream.read(SocketInputStream.java:140)
    at java.base/sun.security.ssl.SSLSocketInputRecord.read(SSLSocketInputRecord.java:448)
    at java.base/sun.security.ssl.SSLSocketInputRecord.bytesInCompletePacket(SSLSocketInputRecord.java:68)
    at java.base/sun.security.ssl.SSLSocketImpl.readApplicationRecord(SSLSocketImpl.java:1104)
    at java.base/sun.security.ssl.SSLSocketImpl$AppInputStream.read(SSLSocketImpl.java:823)
    ... 102 common frames omitted
```

# 自分を悩ませたもの。

- 特定のLB (GCP)だけ、このエラーが発生しやすくなった。(他は安定してエラーが出ない)<br>
なので、<br>
https://stackoverflow.com/questions/49624532/java-net-socketexception-connection-reset-on-gcp<br>
を最初は疑いました。でも、java8だとエラーは出ない。やっぱJava11関係・・・？
- Connection reset系は、安定してstackoverflowで回答がつかない。根本的には、ネットワークの問題だから・・・。
- Java11からSSL通信周りが色々変わっている。
- 発生確率的に、 `javax.net.debug` システムプロパティのデバッグがしんどい。GKEで動かしているので・・・ディスク量的に辛い。
- nodeでtcpdumpをやろうにも発生確率的にやっぱり辛い。

# たどりついた答え

机上でのチェックや、JDK、Spring Boot、HttpClientのバグ確認をしていて、ふと目についたものが、

https://bugs.openjdk.java.net/browse/JDK-8214339

です。
返されるエラーが変わっている・・・？
たしかに、SSLException。handshakeとかのエラーじゃないけど、SSLException。
SSLExceptionでは、 [httpclient](http://hc.apache.org/httpcomponents-client-ga/)の[DefaultHttpRequestRetryHandler#INSTANCE](https://hc.apache.org/httpcomponents-client-ga/httpclient/apidocs/org/apache/http/impl/client/DefaultHttpRequestRetryHandler.html#INSTANCE)では通信が再試行されない。

なるほど。再試行されていないせいで、エラーが余計に発生しているように見えていたのかな。と現在たどり着きました。
たしかにこのエラーが発生する際、接続を開始しようとしてからすぐにエラーが発生します。

バックポートされているみたいですが、どうもSSLExceptionが返されることには変わりなさそうです。

http://hg.openjdk.java.net/jdk/jdk12/rev/9041178a0b69

を見る限り、下記のようになっている・・・というか、`Alert#createSSLException` を呼び出す以上、SSLException以外を返すことができないんですけどね。

```java
+        if ((cause != null) && (cause instanceof IOException)) {
+            ssle = new SSLException(reason);
+        } 
```

# 対応

下記のように、DefaultHttpRequestRetryHandlerのretryRequestをオーバーライドして、

```java:HttpRequestRetryHandler.java
  public class HttpRequestRetryHandler extends DefaultHttpRequestRetryHandler {

    @Override
    public boolean retryRequest(IOException exception, int executionCount, HttpContext context) {
      IOException cause = exception;
      if (exception instanceof SSLException) {
        if (exception.getCause() != null && exception.getCause() instanceof IOException) {
          cause = (IOException) exception.getCause();
        }
      }
      return super.retryRequest(cause, executionCount, context);
    }
  }
```

下記のようにセットしてやればいいのかなーと思ってます。

```java
CloseableHttpClient httpclient = HttpClients.custom()
        .setRetryHandler(new HttpRequestRetryHandler())
        .build();
```

ただ、もっと良い方法がないのか？というのをstackoverflowで質問してみてます。

https://stackoverflow.com/questions/56306216/how-can-i-retry-using-defaulthttprequestretryhandlerhttpclient-when-socketexce

ただ、上にも書いた通り、Connection reset関係は回答をなかなか貰えない、そもそも英語が通じているかわからないので、何か意見貰えるかは怪しいですが・・・。

# 参考

- https://hc.apache.org/httpcomponents-client-ga/tutorial/html/fundamentals.html
- https://bugs.openjdk.java.net/browse/JDK-8214339

## java8の場合のエラー

```
Caused by: org.springframework.web.client.ResourceAccessException: I/O error on POST request for "https://xxxxxxxxx": Connection reset; nested exception is java.net.SocketException: Connection reset
	at org.springframework.web.client.RestTemplate.doExecute(RestTemplate.java:674)
	at org.springframework.web.client.RestTemplate.execute(RestTemplate.java:636)
	at org.springframework.web.client.RestTemplate.exchange(RestTemplate.java:598)
	... 54 common frames omitted
Caused by: java.net.SocketException: Connection reset
	at java.net.SocketInputStream.read(SocketInputStream.java:210)
	at java.net.SocketInputStream.read(SocketInputStream.java:141)
	at sun.security.ssl.InputRecord.readFully(InputRecord.java:465)
	at sun.security.ssl.InputRecord.read(InputRecord.java:503)
	at sun.security.ssl.SSLSocketImpl.readRecord(SSLSocketImpl.java:975)
	at sun.security.ssl.SSLSocketImpl.readDataRecord(SSLSocketImpl.java:933)
	at sun.security.ssl.AppInputStream.read(AppInputStream.java:105)
	at org.apache.http.impl.io.SessionInputBufferImpl.streamRead(SessionInputBufferImpl.java:137)
	at org.apache.http.impl.io.SessionInputBufferImpl.fillBuffer(SessionInputBufferImpl.java:153)
	at org.apache.http.impl.io.SessionInputBufferImpl.readLine(SessionInputBufferImpl.java:282)
	at org.apache.http.impl.conn.DefaultHttpResponseParser.parseHead(DefaultHttpResponseParser.java:138)
	at org.apache.http.impl.conn.DefaultHttpResponseParser.parseHead(DefaultHttpResponseParser.java:56)
	at org.apache.http.impl.io.AbstractMessageParser.parse(AbstractMessageParser.java:259)
	at org.apache.http.impl.DefaultBHttpClientConnection.receiveResponseHeader(DefaultBHttpClientConnection.java:163)
	at org.apache.http.impl.conn.CPoolProxy.receiveResponseHeader(CPoolProxy.java:165)
	at org.apache.http.protocol.HttpRequestExecutor.doReceiveResponse(HttpRequestExecutor.java:273)
	at org.apache.http.protocol.HttpRequestExecutor.execute(HttpRequestExecutor.java:125)
	at org.apache.http.impl.execchain.MainClientExec.execute(MainClientExec.java:272)
	at org.apache.http.impl.execchain.ProtocolExec.execute(ProtocolExec.java:185)
	at org.apache.http.impl.execchain.RetryExec.execute(RetryExec.java:89)
	at org.apache.http.impl.execchain.RedirectExec.execute(RedirectExec.java:111)
	at org.apache.http.impl.client.InternalHttpClient.doExecute(InternalHttpClient.java:185)
	at org.apache.http.impl.client.CloseableHttpClient.execute(CloseableHttpClient.java:83)
	at org.apache.http.impl.client.CloseableHttpClient.execute(CloseableHttpClient.java:56)
	at org.springframework.http.client.HttpComponentsClientHttpRequest.executeInternal(HttpComponentsClientHttpRequest.java:89)
	at org.springframework.http.client.AbstractBufferingClientHttpRequest.executeInternal(AbstractBufferingClientHttpRequest.java:48)
	at org.springframework.http.client.AbstractClientHttpRequest.execute(AbstractClientHttpRequest.java:53)
	at org.springframework.web.client.RestTemplate.doExecute(RestTemplate.java:660)
	... 58 common frames omitted
```