3131import java .net .http .HttpRequest ;
3232import java .net .http .HttpResponse ;
3333import java .net .http .HttpTimeoutException ;
34+ import java .nio .channels .ClosedChannelException ;
3435import java .nio .charset .StandardCharsets ;
3536import java .time .Duration ;
3637import java .util .Base64 ;
3738import java .util .HashMap ;
3839import java .util .List ;
40+ import java .util .Locale ;
3941import java .util .Map ;
4042
4143/**
@@ -423,20 +425,17 @@ private <T> T responseFor(String path, InetAddress ipAddress, Class<T> cls)
423425
424426 private HttpResponse <InputStream > sendWithRetry (HttpRequest request )
425427 throws IOException , InterruptedException {
426- IOException lastException = null ;
427- int attempts = maxRetries + 1 ;
428- for (int i = 0 ; i < attempts ; i ++) {
428+ int attempts = 0 ;
429+ while (true ) {
429430 try {
430431 return httpClient .send (request , HttpResponse .BodyHandlers .ofInputStream ());
431432 } catch (IOException e ) {
432- if (!isRetriableTransportFailure (e ) || i == attempts - 1 ) {
433+ if (!isRetriableTransportFailure (e ) || attempts >= maxRetries ) {
433434 throw e ;
434435 }
435- lastException = e ;
436+ attempts ++ ;
436437 }
437438 }
438- // Unreachable: loop either returns or throws.
439- throw lastException ;
440439 }
441440
442441 private static boolean isRetriableTransportFailure (IOException e ) {
@@ -445,11 +444,14 @@ private static boolean isRetriableTransportFailure(IOException e) {
445444 }
446445 // Walk the cause chain: the JDK HttpClient wraps the underlying transport
447446 // failure in different ways depending on the protocol path. Over HTTP/1.1
448- // a "Connection reset" surfaces as a SocketException; over HTTP/2 (e.g.
449- // a SETTINGS-frame write failure) it may surface as a plain IOException
450- // with the same message. Match by message regardless of class to handle
451- // both, while keeping the type checks for connect-phase timeouts and
452- // request-phase timeouts (which must NEVER be retried).
447+ // a "Connection reset" surfaces as a SocketException; over HTTP/2 a
448+ // SETTINGS-frame write failure may surface as a plain IOException with
449+ // the same message; an HTTP/2 upgrade against an already-closed socket
450+ // surfaces as a (message-less) IOException caused by a
451+ // ClosedChannelException. Match by class for the framing types and by
452+ // message (case-insensitive) for the resets, while keeping the explicit
453+ // checks for connect-phase timeouts and request-phase timeouts (which
454+ // must NEVER be retried).
453455 Throwable t = e ;
454456 while (t != null ) {
455457 if (t instanceof HttpConnectTimeoutException ) {
@@ -461,11 +463,16 @@ private static boolean isRetriableTransportFailure(IOException e) {
461463 if (t instanceof ConnectException ) {
462464 return true ;
463465 }
464- String msg = t .getMessage ();
465- if (msg != null
466- && (msg .contains ("Connection reset" ) || msg .contains ("Broken pipe" ))) {
466+ if (t instanceof ClosedChannelException ) {
467467 return true ;
468468 }
469+ String msg = t .getMessage ();
470+ if (msg != null ) {
471+ String lower = msg .toLowerCase (Locale .ROOT );
472+ if (lower .contains ("connection reset" ) || lower .contains ("broken pipe" )) {
473+ return true ;
474+ }
475+ }
469476 t = t .getCause ();
470477 }
471478 return false ;
0 commit comments