This is an automated email from the ASF dual-hosted git repository.

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/master by this push:
     new bf1bf84571 TODO-342: retry idempotent HTTP requests once on stale 
connection
bf1bf84571 is described below

commit bf1bf84571d95aaaa86e6b1d00f6c31d61c83e5c
Author: James Bognar <[email protected]>
AuthorDate: Mon Aug 10 10:37:07 2026 -0400

    TODO-342: retry idempotent HTTP requests once on stale connection
    
    Add a shared TransportRequest.isSafeToReplay() guard (idempotent method plus
    absent/repeatable body) and use it in JettyHttpTransport and 
ApacheHc45Transport
    to transparently retry exactly once when a pooled keep-alive connection is 
torn
    down before any response bytes are received. POSTs and non-repeatable 
bodies are
    never replayed, and the retry decision is only reached on a provably 
pre-response
    failure. Adds stale-connection retry regression tests (idempotent retried 
once,
    POST not replayed) to both the jetty and apache-httpclient-45 modules.
    
    Also remove the stale root juneau.code-workspace, superseded by the combined
    workspace at ~/workspaces/cursor/juneau.code-workspace.
---
 .../apachehttpclient45/ApacheHc45Transport.java    |  50 +++++++
 ...cheHc45Transport_StaleConnectionRetry_Test.java | 163 +++++++++++++++++++++
 .../rest/client/jetty/JettyHttpTransport.java      |  47 +++++-
 ...ttyHttpTransport_StaleConnectionRetry_Test.java | 142 ++++++++++++++++++
 .../juneau/rest/client/TransportRequest.java       |  30 ++++
 5 files changed, 431 insertions(+), 1 deletion(-)

diff --git 
a/juneau-rest/juneau-rest-client-apache-httpclient-45/src/main/java/org/apache/juneau/rest/client/apachehttpclient45/ApacheHc45Transport.java
 
b/juneau-rest/juneau-rest-client-apache-httpclient-45/src/main/java/org/apache/juneau/rest/client/apachehttpclient45/ApacheHc45Transport.java
index d039bd53ab..a4d11bc79c 100644
--- 
a/juneau-rest/juneau-rest-client-apache-httpclient-45/src/main/java/org/apache/juneau/rest/client/apachehttpclient45/ApacheHc45Transport.java
+++ 
b/juneau-rest/juneau-rest-client-apache-httpclient-45/src/main/java/org/apache/juneau/rest/client/apachehttpclient45/ApacheHc45Transport.java
@@ -17,7 +17,10 @@
 package org.apache.juneau.rest.client.apachehttpclient45;
 
 import java.io.*;
+import java.net.*;
+import java.util.*;
 
+import org.apache.http.*;
 import org.apache.http.client.methods.*;
 import org.apache.http.entity.*;
 import org.apache.http.impl.client.*;
@@ -87,11 +90,33 @@ public final class ApacheHc45Transport implements 
HttpTransport {
 
        @Override /* HttpTransport */
        public TransportResponse execute(TransportRequest request) throws 
TransportException {
+               try {
+                       return sendOnce(request);
+               } catch (StaleConnectionException e) {
+                       // A pooled keep-alive connection was torn down by the 
server before any response was received.
+                       // Replay once on a fresh connection, but only when it 
is provably safe (idempotent + repeatable body).
+                       if (! request.isSafeToReplay())
+                               throw e.asTransportException();
+                       try {
+                               return sendOnce(request);
+                       } catch (StaleConnectionException e2) {
+                               throw e2.asTransportException();
+                       }
+               }
+       }
+
+       // Performs a single HTTP exchange.  Throws StaleConnectionException (a 
retryable signal) when the failure is a
+       // pre-response stale-connection failure; throws TransportException for 
every other failure.
+       private TransportResponse sendOnce(TransportRequest request) throws 
TransportException, StaleConnectionException {
                var hcRequest = buildHcRequest(request);
                CloseableHttpResponse hcResponse;
                try {
                        hcResponse = httpClient.execute(hcRequest);
                } catch (IOException e) {
+                       // httpClient.execute() only returns once response 
headers have been read, so any IOException here
+                       // occurred before any response bytes were received.
+                       if (isStaleConnectionFailure(e))
+                               throw new StaleConnectionException(e);
                        throw new TransportException("HTTP transport error: " + 
e.getMessage(), e);
                }
                try {
@@ -103,6 +128,16 @@ public final class ApacheHc45Transport implements 
HttpTransport {
                }
        }
 
+       // A failure is a stale-connection (pre-response) failure when the 
server closed the pooled connection before
+       // sending any response.  Apache HttpClient 4.5 surfaces this as a 
NoHttpResponseException ("failed to respond")
+       // or a connection-reset SocketException.  HttpClient 4.5's default 
retry handler does not retry these for
+       // entity-enclosing idempotent methods (e.g. PUT) or for POST, so the 
transport applies its own idempotency-safe
+       // retry here.
+       private static boolean isStaleConnectionFailure(IOException e) {
+               return e instanceof NoHttpResponseException
+                       || (e instanceof SocketException && e.getMessage() != 
null && e.getMessage().toLowerCase(Locale.ROOT).contains("reset"));
+       }
+
        private static void closeQuietly(CloseableHttpResponse hcResponse) {
                try {
                        hcResponse.close();
@@ -157,6 +192,21 @@ public final class ApacheHc45Transport implements 
HttpTransport {
        // TransportBodyEntity — bridges TransportBody to Apache HttpEntity
        // 
-----------------------------------------------------------------------------------------------------------------
 
+       // Internal signal that a pre-response stale-connection failure 
occurred and may be retried once.  Carries the
+       // original cause so the caller can build the user-visible 
TransportException if the retry is not attempted or
+       // also fails.
+       private static final class StaleConnectionException extends Exception {
+               private static final long serialVersionUID = 1L;
+
+               StaleConnectionException(Throwable cause) {
+                       super(cause);
+               }
+
+               TransportException asTransportException() {
+                       return new TransportException("HTTP transport error: " 
+ getCause().getMessage(), getCause());
+               }
+       }
+
        /**
         * Bridges a {@link TransportBody} to Apache HttpClient's {@link 
AbstractHttpEntity}.
         */
diff --git 
a/juneau-rest/juneau-rest-client-apache-httpclient-45/src/test/java/org/apache/juneau/rest/client/ApacheHc45Transport_StaleConnectionRetry_Test.java
 
b/juneau-rest/juneau-rest-client-apache-httpclient-45/src/test/java/org/apache/juneau/rest/client/ApacheHc45Transport_StaleConnectionRetry_Test.java
new file mode 100644
index 0000000000..98698291ca
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-client-apache-httpclient-45/src/test/java/org/apache/juneau/rest/client/ApacheHc45Transport_StaleConnectionRetry_Test.java
@@ -0,0 +1,163 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.client;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.net.*;
+import java.nio.charset.*;
+import java.util.concurrent.atomic.*;
+
+import org.apache.juneau.http.entity.*;
+import org.apache.juneau.rest.client.apachehttpclient45.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Verifies that {@link ApacheHc45Transport} transparently retries a 
provably-idempotent request once when a pooled
+ * keep-alive connection is torn down by the server before any response is 
received, while never replaying a
+ * non-idempotent request (e.g. {@code POST}) under the same condition.
+ *
+ * <p>
+ * The positive case uses {@code PUT} deliberately: HttpClient 4.5's own 
default retry handler does not retry
+ * entity-enclosing methods such as {@code PUT} once the request has been 
sent, so a successful retry here proves the
+ * transport-level retry — not the underlying client's built-in handler — is 
doing the work.
+ */
+@SuppressWarnings({
+       "resource" // Transport/client instances are short-lived test fixtures.
+})
+class ApacheHc45Transport_StaleConnectionRetry_Test {
+
+       // 
=================================================================================================================
+       // A — Idempotent request is retried once on a pre-response 
stale-connection failure
+       // 
=================================================================================================================
+
+       @Test
+       void a01_put_retriedOnceAfterStaleConnection() throws Exception {
+               try (var server = new A01_StaleServer(1)) {
+                       var transport = ApacheHc45Transport.create();
+                       try (var client = 
RestClient.builder().transport(transport).rootUrl(server.rootUrl()).build()) {
+                               try (var response = 
client.put("/x").body(StringBody.of("data", "text/plain")).run()) {
+                                       assertEquals(200, 
response.getStatusCode());
+                                       assertEquals("OK", 
response.getBodyAsString());
+                               }
+                       }
+                       // One stale connection + one successful retry 
connection.
+                       assertEquals(2, server.connectionCount());
+               }
+       }
+
+       // 
=================================================================================================================
+       // B — Non-idempotent request is NOT retried on the same failure
+       // 
=================================================================================================================
+
+       @Test
+       void b01_post_notRetriedOnStaleConnection() throws Exception {
+               try (var server = new A01_StaleServer(1)) {
+                       var transport = ApacheHc45Transport.create();
+                       try (var client = 
RestClient.builder().transport(transport).rootUrl(server.rootUrl()).build()) {
+                               assertThrows(TransportException.class, () -> 
client.post("/x").body(StringBody.of("data", "text/plain")).run());
+                       }
+                       // Only the initial connection — the POST must not be 
replayed.
+                       assertEquals(1, server.connectionCount());
+               }
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // Test double: a server that tears down the first N connections after 
reading the request (no response), then
+       // responds 200 on subsequent connections — reproducing a stale 
pooled-connection reuse failure.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       private static final class A01_StaleServer implements AutoCloseable {
+
+               private final ServerSocket serverSocket;
+               private final int staleConnections;
+               private final AtomicInteger connectionCount = new 
AtomicInteger();
+
+               A01_StaleServer(int staleConnections) throws IOException {
+                       this.staleConnections = staleConnections;
+                       this.serverSocket = new ServerSocket(0);
+                       var acceptThread = new Thread(this::acceptLoop, 
"hc45-stale-server");
+                       acceptThread.setDaemon(true);
+                       acceptThread.start();
+               }
+
+               String rootUrl() {
+                       return "http://localhost:"; + 
serverSocket.getLocalPort();
+               }
+
+               int connectionCount() {
+                       return connectionCount.get();
+               }
+
+               private void acceptLoop() {
+                       while (! serverSocket.isClosed()) {
+                               try (var socket = serverSocket.accept()) {
+                                       var n = 
connectionCount.incrementAndGet();
+                                       consumeRequest(socket.getInputStream());
+                                       if (n > staleConnections) {
+                                               var body = 
"OK".getBytes(StandardCharsets.UTF_8);
+                                               var out = 
socket.getOutputStream();
+                                               out.write(("HTTP/1.1 200 
OK\r\nContent-Type: text/plain\r\nContent-Length: " + body.length + 
"\r\nConnection: close\r\n\r\n").getBytes(StandardCharsets.UTF_8));
+                                               out.write(body);
+                                               out.flush();
+                                       }
+                                       // Otherwise close without responding 
(try-with-resources) to simulate a torn-down pooled connection.
+                               } catch (IOException e) {
+                                       return; // Server socket closed during 
shutdown.
+                               }
+                       }
+               }
+
+               // Reads through the end of the request headers ("\r\n\r\n") 
plus any declared Content-Length body so the
+               // client has fully written its request before the connection 
is closed, guaranteeing a pre-response failure
+               // rather than a mid-write reset.
+               private static void consumeRequest(InputStream in) throws 
IOException {
+                       var headers = new StringBuilder();
+                       var state = 0;
+                       int b;
+                       while ((b = in.read()) != -1) {
+                               headers.append((char) b);
+                               var expected = (state == 0 || state == 2) ? 
'\r' : '\n';
+                               if (b == expected) {
+                                       if (++state == 4)
+                                               break;
+                               } else {
+                                       state = (b == '\r') ? 1 : 0;
+                               }
+                       }
+                       var contentLength = 
parseContentLength(headers.toString());
+                       for (var i = 0; i < contentLength && in.read() != -1; 
i++) {
+                               // Drain the request body so the exchange is 
complete before the connection is closed.
+                       }
+               }
+
+               private static int parseContentLength(String headers) {
+                       for (var line : headers.split("\r\n")) {
+                               var idx = line.indexOf(':');
+                               if (idx > 0 && 
"content-length".equalsIgnoreCase(line.substring(0, idx).trim()))
+                                       return 
Integer.parseInt(line.substring(idx + 1).trim());
+                       }
+                       return 0;
+               }
+
+               @Override
+               public void close() throws IOException {
+                       serverSocket.close();
+               }
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-client-jetty/src/main/java/org/apache/juneau/rest/client/jetty/JettyHttpTransport.java
 
b/juneau-rest/juneau-rest-client-jetty/src/main/java/org/apache/juneau/rest/client/jetty/JettyHttpTransport.java
index eb3d312d51..323d41e108 100644
--- 
a/juneau-rest/juneau-rest-client-jetty/src/main/java/org/apache/juneau/rest/client/jetty/JettyHttpTransport.java
+++ 
b/juneau-rest/juneau-rest-client-jetty/src/main/java/org/apache/juneau/rest/client/jetty/JettyHttpTransport.java
@@ -19,6 +19,7 @@ package org.apache.juneau.rest.client.jetty;
 import static org.apache.juneau.commons.utils.Shorts.*;
 
 import java.io.*;
+import java.nio.channels.*;
 import java.util.concurrent.*;
 
 import org.apache.juneau.rest.client.*;
@@ -95,6 +96,24 @@ public final class JettyHttpTransport implements 
HttpTransport {
 
        @Override /* HttpTransport */
        public TransportResponse execute(TransportRequest request) throws 
TransportException {
+               try {
+                       return sendOnce(request);
+               } catch (StaleConnectionException e) {
+                       // A pooled keep-alive connection was torn down by the 
server before any response was received.
+                       // Replay once on a fresh connection, but only when it 
is provably safe (idempotent + repeatable body).
+                       if (! request.isSafeToReplay())
+                               throw e.asTransportException();
+                       try {
+                               return sendOnce(request);
+                       } catch (StaleConnectionException e2) {
+                               throw e2.asTransportException();
+                       }
+               }
+       }
+
+       // Performs a single HTTP exchange.  Throws StaleConnectionException (a 
retryable signal) when the failure is a
+       // pre-response stale-connection failure; throws TransportException for 
every other failure.
+       private TransportResponse sendOnce(TransportRequest request) throws 
TransportException, StaleConnectionException {
                var jettyRequest = buildJettyRequest(request);
                var listener = new InputStreamResponseListener();
                jettyRequest.send(listener);
@@ -112,11 +131,22 @@ public final class JettyHttpTransport implements 
HttpTransport {
                        throw new TransportException("HTTP request timed out", 
e);
                } catch (ExecutionException e) {
                        abortQuietly(listener);
-                       throw new TransportException("HTTP transport error: " + 
e.getCause().getMessage(), e.getCause());
+                       var cause = e.getCause();
+                       if (isStaleConnectionFailure(cause))
+                               throw new StaleConnectionException(cause);
+                       throw new TransportException("HTTP transport error: " + 
cause.getMessage(), cause);
                }
                return buildTransportResponse(jettyResponse, 
listener.getInputStream());
        }
 
+       // A failure is a stale-connection (pre-response) failure when the 
server closed the pooled connection before
+       // sending any response.  Jetty surfaces this as an EOFException 
(org.eclipse.jetty.io.EofException extends
+       // EOFException) or a ClosedChannelException.  Because listener.get() 
only returns once response headers have
+       // arrived, any such failure here is guaranteed to have occurred before 
any response bytes were read.
+       private static boolean isStaleConnectionFailure(Throwable cause) {
+               return cause instanceof EOFException || cause instanceof 
ClosedChannelException;
+       }
+
        // Releases the Jetty response content when the response never reaches 
the caller (interrupt/timeout/error),
        // so the connection is not abandoned.  Closing the listener's input 
stream aborts and releases the exchange.
        private static void abortQuietly(InputStreamResponseListener listener) {
@@ -177,4 +207,19 @@ public final class JettyHttpTransport implements 
HttpTransport {
                jettyResponse.getHeaders().forEach(field -> 
builder.header(field.getName(), field.getValue()));
                return builder.build();
        }
+
+       // Internal signal that a pre-response stale-connection failure 
occurred and may be retried once.  Carries the
+       // original cause so the caller can build the user-visible 
TransportException if the retry is not attempted or
+       // also fails.
+       private static final class StaleConnectionException extends Exception {
+               private static final long serialVersionUID = 1L;
+
+               StaleConnectionException(Throwable cause) {
+                       super(cause);
+               }
+
+               TransportException asTransportException() {
+                       return new TransportException("HTTP transport error: " 
+ getCause().getMessage(), getCause());
+               }
+       }
 }
diff --git 
a/juneau-rest/juneau-rest-client-jetty/src/test/java/org/apache/juneau/rest/client/JettyHttpTransport_StaleConnectionRetry_Test.java
 
b/juneau-rest/juneau-rest-client-jetty/src/test/java/org/apache/juneau/rest/client/JettyHttpTransport_StaleConnectionRetry_Test.java
new file mode 100644
index 0000000000..81048c6d04
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-client-jetty/src/test/java/org/apache/juneau/rest/client/JettyHttpTransport_StaleConnectionRetry_Test.java
@@ -0,0 +1,142 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.client;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.net.*;
+import java.nio.charset.*;
+import java.util.concurrent.atomic.*;
+
+import org.apache.juneau.http.entity.*;
+import org.apache.juneau.rest.client.jetty.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Verifies that {@link JettyHttpTransport} transparently retries a 
provably-idempotent request once when a pooled
+ * keep-alive connection is torn down by the server before any response is 
received, while never replaying a
+ * non-idempotent request (e.g. {@code POST}) under the same condition.
+ */
+@SuppressWarnings({
+       "resource" // Transport/client instances are short-lived test fixtures.
+})
+class JettyHttpTransport_StaleConnectionRetry_Test {
+
+       // 
=================================================================================================================
+       // A — Idempotent request is retried once on a pre-response 
stale-connection failure
+       // 
=================================================================================================================
+
+       @Test
+       void a01_get_retriedOnceAfterStaleConnection() throws Exception {
+               try (var server = new A01_StaleServer(1)) {
+                       var transport = JettyHttpTransport.create();
+                       try (var client = 
RestClient.builder().transport(transport).rootUrl(server.rootUrl()).build()) {
+                               try (var response = client.get("/x").run()) {
+                                       assertEquals(200, 
response.getStatusCode());
+                                       assertEquals("OK", 
response.getBodyAsString());
+                               }
+                       }
+                       // One stale connection + one successful retry 
connection.
+                       assertEquals(2, server.connectionCount());
+               }
+       }
+
+       // 
=================================================================================================================
+       // B — Non-idempotent request is NOT retried on the same failure
+       // 
=================================================================================================================
+
+       @Test
+       void b01_post_notRetriedOnStaleConnection() throws Exception {
+               try (var server = new A01_StaleServer(1)) {
+                       var transport = JettyHttpTransport.create();
+                       try (var client = 
RestClient.builder().transport(transport).rootUrl(server.rootUrl()).build()) {
+                               assertThrows(TransportException.class, () -> 
client.post("/x").body(StringBody.of("data", "text/plain")).run());
+                       }
+                       // Only the initial connection — the POST must not be 
replayed.
+                       assertEquals(1, server.connectionCount());
+               }
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // Test double: a server that tears down the first N connections after 
reading the request (no response), then
+       // responds 200 on subsequent connections — reproducing a stale 
pooled-connection reuse failure.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       private static final class A01_StaleServer implements AutoCloseable {
+
+               private final ServerSocket serverSocket;
+               private final int staleConnections;
+               private final AtomicInteger connectionCount = new 
AtomicInteger();
+
+               A01_StaleServer(int staleConnections) throws IOException {
+                       this.staleConnections = staleConnections;
+                       this.serverSocket = new ServerSocket(0);
+                       var acceptThread = new Thread(this::acceptLoop, 
"jetty-stale-server");
+                       acceptThread.setDaemon(true);
+                       acceptThread.start();
+               }
+
+               String rootUrl() {
+                       return "http://localhost:"; + 
serverSocket.getLocalPort();
+               }
+
+               int connectionCount() {
+                       return connectionCount.get();
+               }
+
+               private void acceptLoop() {
+                       while (! serverSocket.isClosed()) {
+                               try (var socket = serverSocket.accept()) {
+                                       var n = 
connectionCount.incrementAndGet();
+                                       
consumeRequestHeaders(socket.getInputStream());
+                                       if (n > staleConnections) {
+                                               var body = 
"OK".getBytes(StandardCharsets.UTF_8);
+                                               var out = 
socket.getOutputStream();
+                                               out.write(("HTTP/1.1 200 
OK\r\nContent-Type: text/plain\r\nContent-Length: " + body.length + 
"\r\nConnection: close\r\n\r\n").getBytes(StandardCharsets.UTF_8));
+                                               out.write(body);
+                                               out.flush();
+                                       }
+                                       // Otherwise close without responding 
(try-with-resources) to simulate a torn-down pooled connection.
+                               } catch (IOException e) {
+                                       return; // Server socket closed during 
shutdown.
+                               }
+                       }
+               }
+
+               // Reads through the end of the request headers ("\r\n\r\n") so 
the client has fully written its request
+               // before the connection is closed, guaranteeing a pre-response 
failure rather than a mid-write reset.
+               private static void consumeRequestHeaders(InputStream in) 
throws IOException {
+                       var state = 0;
+                       int b;
+                       while ((b = in.read()) != -1) {
+                               var expected = (state == 0 || state == 2) ? 
'\r' : '\n';
+                               if (b == expected) {
+                                       if (++state == 4)
+                                               return;
+                               } else {
+                                       state = (b == '\r') ? 1 : 0;
+                               }
+                       }
+               }
+
+               @Override
+               public void close() throws IOException {
+                       serverSocket.close();
+               }
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/TransportRequest.java
 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/TransportRequest.java
index e674c92db1..22beac689d 100644
--- 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/TransportRequest.java
+++ 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/TransportRequest.java
@@ -42,6 +42,10 @@ import java.util.*;
  */
 public final class TransportRequest {
 
+       // Methods defined as idempotent by RFC 7231 §4.2.2 — replaying them 
cannot change server state beyond a
+       // single application of the request, so a transport may safely re-send 
them once on a fresh connection.
+       private static final Set<String> IDEMPOTENT_METHODS = Set.of("GET", 
"HEAD", "PUT", "DELETE", "OPTIONS", "TRACE");
+
        private final String method;
        private final URI uri;
        private final List<TransportHeader> headers;
@@ -110,6 +114,32 @@ public final class TransportRequest {
                return body;
        }
 
+       /**
+        * Returns {@code true} if this request can be transparently re-sent 
once after a stale-connection failure.
+        *
+        * <p>
+        * A pooled keep-alive socket can be torn down by the server between 
requests; when a transport then reuses
+        * that socket the first write fails before any response is received 
(e.g. an early {@code EOF} or a
+        * {@code NoHttpResponseException}).  Such a failure is safe to retry 
on a fresh connection <b>only</b> when
+        * replaying the request cannot cause a duplicate side effect, which 
requires both of:
+        * <ul>
+        *      <li>an <a class="doclink" 
href="https://datatracker.ietf.org/doc/html/rfc7231#section-4.2.2";>idempotent</a>
+        *              HTTP method ({@code GET}, {@code HEAD}, {@code PUT}, 
{@code DELETE}, {@code OPTIONS}, {@code TRACE}) — never
+        *              {@code POST} or any other non-idempotent method, and
+        *      <li>a body that is either absent or {@link 
TransportBody#isRepeatable() repeatable}, so the exact same bytes
+        *              can be written again.
+        * </ul>
+        *
+        * <p>
+        * When in doubt this returns {@code false} — the caller must fail 
closed rather than risk double-executing a
+        * request.
+        *
+        * @return {@code true} if the request is provably safe to replay once.
+        */
+       public boolean isSafeToReplay() {
+               return 
IDEMPOTENT_METHODS.contains(method.toUpperCase(Locale.ROOT)) && (body == null 
|| body.isRepeatable());
+       }
+
        /**
         * Returns the per-call response timeout, or {@code null} if none was 
set.
         *

Reply via email to