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.
*