2525import java .net .http .HttpRequest ;
2626import java .net .http .HttpResponse ;
2727import java .net .http .HttpTimeoutException ;
28+ import java .nio .channels .ClosedChannelException ;
2829import java .nio .charset .StandardCharsets ;
2930import java .time .Duration ;
3031import java .util .Base64 ;
3132import java .util .Collections ;
3233import java .util .HashMap ;
3334import java .util .List ;
35+ import java .util .Locale ;
3436import java .util .Map ;
3537
3638/**
@@ -386,20 +388,17 @@ private <T> T responseFor(String service, AbstractModel transaction, Class<T> cl
386388
387389 private HttpResponse <InputStream > sendWithRetry (HttpRequest request )
388390 throws IOException , InterruptedException {
389- IOException lastException = null ;
390- int attempts = maxRetries + 1 ;
391- for (int i = 0 ; i < attempts ; i ++) {
391+ int attempts = 0 ;
392+ while (true ) {
392393 try {
393394 return httpClient .send (request , HttpResponse .BodyHandlers .ofInputStream ());
394395 } catch (IOException e ) {
395- if (!isRetriableTransportFailure (e ) || i == attempts - 1 ) {
396+ if (!isRetriableTransportFailure (e ) || attempts >= maxRetries ) {
396397 throw e ;
397398 }
398- lastException = e ;
399+ attempts ++ ;
399400 }
400401 }
401- // Unreachable: loop either returns or throws.
402- throw lastException ;
403402 }
404403
405404 private static boolean isRetriableTransportFailure (IOException e ) {
@@ -408,11 +407,14 @@ private static boolean isRetriableTransportFailure(IOException e) {
408407 }
409408 // Walk the cause chain: the JDK HttpClient wraps the underlying transport
410409 // failure in different ways depending on the protocol path. Over HTTP/1.1
411- // a "Connection reset" surfaces as a SocketException; over HTTP/2 (e.g.
412- // a SETTINGS-frame write failure) it may surface as a plain IOException
413- // with the same message. Match by message regardless of class to handle
414- // both, while keeping the type checks for connect-phase timeouts and
415- // request-phase timeouts (which must NEVER be retried).
410+ // a "Connection reset" surfaces as a SocketException; over HTTP/2 a
411+ // SETTINGS-frame write failure may surface as a plain IOException with
412+ // the same message; an HTTP/2 upgrade against an already-closed socket
413+ // surfaces as a (message-less) IOException caused by a
414+ // ClosedChannelException. Match by class for the framing types and by
415+ // message (case-insensitive) for the resets, while keeping the explicit
416+ // checks for connect-phase timeouts and request-phase timeouts (which
417+ // must NEVER be retried).
416418 Throwable t = e ;
417419 while (t != null ) {
418420 if (t instanceof HttpConnectTimeoutException ) {
@@ -424,11 +426,16 @@ private static boolean isRetriableTransportFailure(IOException e) {
424426 if (t instanceof ConnectException ) {
425427 return true ;
426428 }
427- String msg = t .getMessage ();
428- if (msg != null
429- && (msg .contains ("Connection reset" ) || msg .contains ("Broken pipe" ))) {
429+ if (t instanceof ClosedChannelException ) {
430430 return true ;
431431 }
432+ String msg = t .getMessage ();
433+ if (msg != null ) {
434+ String lower = msg .toLowerCase (Locale .ROOT );
435+ if (lower .contains ("connection reset" ) || lower .contains ("broken pipe" )) {
436+ return true ;
437+ }
438+ }
432439 t = t .getCause ();
433440 }
434441 return false ;
0 commit comments