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

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


The following commit(s) were added to refs/heads/master by this push:
     new 288f977711 [rest] Do not replay a POST the server cannot absorb twice 
(#9122)
288f977711 is described below

commit 288f977711af972371a409d17c9e8b45fb97bdb4
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Mon Aug 10 13:51:11 2026 +0800

    [rest] Do not replay a POST the server cannot absorb twice (#9122)
---
 .../rest/ExponentialHttpRequestRetryStrategy.java  |  29 ++++
 .../java/org/apache/paimon/rest/HttpClient.java    |  25 +++-
 .../java/org/apache/paimon/rest/RESTRequest.java   |  24 +++-
 .../paimon/rest/HttpClientRetrySafetyTest.java     | 146 +++++++++++++++++++++
 .../TestExponentialHttpRequestRetryStrategy.java   |  18 +++
 5 files changed, 237 insertions(+), 5 deletions(-)

diff --git 
a/paimon-api/src/main/java/org/apache/paimon/rest/ExponentialHttpRequestRetryStrategy.java
 
b/paimon-api/src/main/java/org/apache/paimon/rest/ExponentialHttpRequestRetryStrategy.java
index e8e621a64d..08e7215c11 100644
--- 
a/paimon-api/src/main/java/org/apache/paimon/rest/ExponentialHttpRequestRetryStrategy.java
+++ 
b/paimon-api/src/main/java/org/apache/paimon/rest/ExponentialHttpRequestRetryStrategy.java
@@ -23,6 +23,7 @@ import org.apache.paimon.utils.Preconditions;
 import org.apache.paimon.shade.guava30.com.google.common.collect.ImmutableSet;
 
 import org.apache.hc.client5.http.HttpRequestRetryStrategy;
+import org.apache.hc.client5.http.protocol.HttpClientContext;
 import org.apache.hc.client5.http.utils.DateUtils;
 import org.apache.hc.core5.concurrent.CancellableDependency;
 import org.apache.hc.core5.http.ConnectionClosedException;
@@ -35,6 +36,7 @@ import org.apache.hc.core5.http.Method;
 import org.apache.hc.core5.http.protocol.HttpContext;
 import org.apache.hc.core5.util.TimeValue;
 
+import javax.annotation.Nullable;
 import javax.net.ssl.SSLException;
 
 import java.io.IOException;
@@ -47,6 +49,16 @@ import java.util.Set;
 import java.util.concurrent.ThreadLocalRandom;
 
 class ExponentialHttpRequestRetryStrategy implements HttpRequestRetryStrategy {
+
+    /**
+     * Context attribute marking one exchange as "must not be sent twice". A 
429 or a 503 can reach
+     * the client from a proxy after the server already applied the request, 
so replaying it applies
+     * it again; for a request that is not idempotent by content that is a 
silent double apply. The
+     * mark travels in the context rather than in the request, so it never 
reaches the wire and
+     * survives whatever the exec chain does to the request object.
+     */
+    static final String RETRY_UNSAFE_ATTRIBUTE = "paimon.rest.retry-unsafe";
+
     private final int maxRetries;
     private final Set<Class<? extends IOException>> nonRetriableExceptions;
     private final Set<Integer> retriableCodes;
@@ -98,9 +110,26 @@ class ExponentialHttpRequestRetryStrategy implements 
HttpRequestRetryStrategy {
 
     @Override
     public boolean retryRequest(HttpResponse response, int execCount, 
HttpContext context) {
+        if (isRetryUnsafe(context)) {
+            // The status says nothing about whether the server applied the 
request: a 503 from an
+            // intermediary can follow a request that already took effect. 
Replaying it would apply
+            // it twice with nobody the wiser, so the failure goes back to the 
caller instead.
+            return false;
+        }
         return execCount <= maxRetries && 
retriableCodes.contains(response.getCode());
     }
 
+    /** A context for one exchange that must be sent exactly once. */
+    static HttpClientContext retryUnsafeContext() {
+        HttpClientContext context = HttpClientContext.create();
+        context.setAttribute(RETRY_UNSAFE_ATTRIBUTE, Boolean.TRUE);
+        return context;
+    }
+
+    static boolean isRetryUnsafe(@Nullable HttpContext context) {
+        return context != null && 
Boolean.TRUE.equals(context.getAttribute(RETRY_UNSAFE_ATTRIBUTE));
+    }
+
     @Override
     public TimeValue getRetryInterval(HttpResponse response, int execCount, 
HttpContext context) {
         // a server may send a 429 / 503 with a Retry-After header
diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/HttpClient.java 
b/paimon-api/src/main/java/org/apache/paimon/rest/HttpClient.java
index 311a1a2d4a..8205dfe212 100644
--- a/paimon-api/src/main/java/org/apache/paimon/rest/HttpClient.java
+++ b/paimon-api/src/main/java/org/apache/paimon/rest/HttpClient.java
@@ -36,8 +36,12 @@ import 
org.apache.hc.client5.http.classic.methods.HttpUriRequestBase;
 import org.apache.hc.core5.http.ClassicHttpResponse;
 import org.apache.hc.core5.http.ContentType;
 import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.io.HttpClientResponseHandler;
 import org.apache.hc.core5.http.io.entity.StringEntity;
 import org.apache.hc.core5.http.message.BasicHeader;
+import org.apache.hc.core5.http.protocol.HttpContext;
+
+import javax.annotation.Nullable;
 
 import java.io.IOException;
 import java.util.Arrays;
@@ -100,7 +104,13 @@ public class HttpClient implements RESTClient {
         }
         Header[] authHeaders = getHeaders(path, "POST", encodedBody, 
restAuthFunction);
         httpPost.setHeaders(authHeaders);
-        return exec(httpPost, responseType);
+        // A POST the server cannot absorb twice is sent exactly once, 
whatever the status says.
+        return exec(
+                httpPost,
+                responseType,
+                body != null && !body.isRetrySafe()
+                        ? 
ExponentialHttpRequestRetryStrategy.retryUnsafeContext()
+                        : null);
     }
 
     @Override
@@ -127,9 +137,13 @@ public class HttpClient implements RESTClient {
     }
 
     private <T extends RESTResponse> T exec(HttpUriRequestBase request, 
Class<T> responseType) {
+        return exec(request, responseType, null);
+    }
+
+    private <T extends RESTResponse> T exec(
+            HttpUriRequestBase request, Class<T> responseType, @Nullable 
HttpContext context) {
         try {
-            return DEFAULT_HTTP_CLIENT.execute(
-                    request,
+            HttpClientResponseHandler<T> handler =
                     response -> {
                         String responseBodyStr = 
RESTUtil.extractResponseBodyAsString(response);
                         if (!RESTUtil.isSuccessful(response)) {
@@ -159,7 +173,10 @@ public class HttpClient implements RESTClient {
                         } else {
                             throw new RESTException("response body is null.");
                         }
-                    });
+                    };
+            return context == null
+                    ? DEFAULT_HTTP_CLIENT.execute(request, handler)
+                    : DEFAULT_HTTP_CLIENT.execute(request, context, handler);
         } catch (IOException e) {
             // No cause: a redirect/protocol error message can echo the target 
URL (a signed URL).
             throw new RESTException(
diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/RESTRequest.java 
b/paimon-api/src/main/java/org/apache/paimon/rest/RESTRequest.java
index 9c6758df14..35412bcb22 100644
--- a/paimon-api/src/main/java/org/apache/paimon/rest/RESTRequest.java
+++ b/paimon-api/src/main/java/org/apache/paimon/rest/RESTRequest.java
@@ -18,5 +18,27 @@
 
 package org.apache.paimon.rest;
 
+import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonIgnore;
+
 /** Interface to mark a REST request. */
-public interface RESTRequest extends RESTMessage {}
+public interface RESTRequest extends RESTMessage {
+
+    /**
+     * Whether sending this request a second time leaves the server where 
sending it once does.
+     *
+     * <p>This is how the client treats the request, not something the server 
is told: it is a
+     * getter on a serialized type and must stay off the wire.
+     *
+     * <p>POST is not idempotent by method, but nearly every request Paimon 
sends over it is by
+     * content — registering a partition, creating a database, committing a 
snapshot the server
+     * already holds — so the client retries them after a 429 or a 503, which 
is the only defence
+     * against a rate limiter or a restarting node. A request that reports an 
increment is the
+     * exception: a proxy answering 503 after the server already applied it 
turns an automatic retry
+     * into a double count that no caller can see. Such a request says so here 
and is sent exactly
+     * once; the failure reaches the caller, which can decide.
+     */
+    @JsonIgnore
+    default boolean isRetrySafe() {
+        return true;
+    }
+}
diff --git 
a/paimon-api/src/test/java/org/apache/paimon/rest/HttpClientRetrySafetyTest.java
 
b/paimon-api/src/test/java/org/apache/paimon/rest/HttpClientRetrySafetyTest.java
new file mode 100644
index 0000000000..8aca4d9947
--- /dev/null
+++ 
b/paimon-api/src/test/java/org/apache/paimon/rest/HttpClientRetrySafetyTest.java
@@ -0,0 +1,146 @@
+/*
+ * 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.paimon.rest;
+
+import org.apache.paimon.rest.exceptions.ServiceUnavailableException;
+import org.apache.paimon.rest.responses.ListDatabasesResponse;
+
+import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonGetter;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests that a POST declaring itself unsafe to replay is sent exactly once, 
and that every other
+ * POST keeps the 429/503 retry it has always had.
+ *
+ * <p>The server here refuses only the first attempt, so a retried request 
succeeds on its second
+ * one: the request count separates "sent once" from "sent again" without 
waiting out five backoffs.
+ */
+public class HttpClientRetrySafetyTest {
+
+    private static final String PATH = "/databases";
+
+    private HttpServer server;
+    private HttpClient client;
+    private final AtomicInteger requests = new AtomicInteger();
+
+    @BeforeEach
+    public void setUp() throws Exception {
+        server = HttpServer.create(new InetSocketAddress(0), 0);
+        server.createContext(
+                PATH,
+                exchange -> {
+                    if (requests.incrementAndGet() == 1) {
+                        // A proxy answering 503 says nothing about whether 
the server applied the
+                        // request; this is exactly the shape that applies a 
request twice.
+                        respond(exchange, 503, 
"{\"message\":\"busy\",\"code\":503}");
+                    } else {
+                        respond(exchange, 200, "{\"databases\":[\"db\"]}");
+                    }
+                });
+        server.start();
+        client = new HttpClient("http://127.0.0.1:"; + 
server.getAddress().getPort());
+    }
+
+    @AfterEach
+    public void tearDown() {
+        if (server != null) {
+            server.stop(0);
+        }
+    }
+
+    @Test
+    public void testARequestThatDeclaresItselfUnsafeIsNotRetried() {
+        assertThatThrownBy(() -> post(new UnsafeToRetry()))
+                .isInstanceOf(ServiceUnavailableException.class);
+
+        // Retrying would apply the same request a second time, and nothing 
downstream could see it.
+        assertThat(requests.get()).isEqualTo(1);
+    }
+
+    @Test
+    public void testARequestThatNeverHeardOfRetrySafetyKeepsItsRetry() {
+        // The regression this guards against is the global one: every request 
type implements
+        // RESTRequest and rides the interface default, so the case above 
would still pass if the
+        // default flipped to false and silently took 429/503 retry away from 
commits, database
+        // creation and every other POST in the catalog.
+        assertThat(new DefaultRetrySafety().isRetrySafe()).isTrue();
+        assertThat(post(new DefaultRetrySafety())).isNotNull();
+
+        assertThat(requests.get()).isEqualTo(2);
+    }
+
+    @Test
+    public void testRetrySafetyNeverReachesTheWire() {
+        // isRetrySafe is how the client treats the request, not something the 
server is told. It is
+        // a getter on a serialized type, so without @JsonIgnore it would show 
up in the body.
+        assertThat(RESTUtil.encodedBody(new 
UnsafeToRetry())).doesNotContain("retrySafe");
+        assertThat(RESTUtil.encodedBody(new 
DefaultRetrySafety())).doesNotContain("retrySafe");
+    }
+
+    /** A request that leaves {@link RESTRequest#isRetrySafe()} at its 
default, as all others do. */
+    private static class DefaultRetrySafety implements RESTRequest {
+
+        @JsonGetter("name")
+        public String getName() {
+            return "db";
+        }
+    }
+
+    /** A request that must reach the server at most once. */
+    private static class UnsafeToRetry implements RESTRequest {
+
+        @JsonGetter("name")
+        public String getName() {
+            return "db";
+        }
+
+        @Override
+        public boolean isRetrySafe() {
+            return false;
+        }
+    }
+
+    private ListDatabasesResponse post(RESTRequest request) {
+        return client.post(PATH, request, ListDatabasesResponse.class, null);
+    }
+
+    private static void respond(HttpExchange exchange, int statusCode, String 
body)
+            throws IOException {
+        byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+        exchange.getResponseHeaders().add("Content-Type", "application/json");
+        exchange.sendResponseHeaders(statusCode, bytes.length);
+        try (OutputStream out = exchange.getResponseBody()) {
+            out.write(bytes);
+        }
+    }
+}
diff --git 
a/paimon-api/src/test/java/org/apache/paimon/rest/TestExponentialHttpRequestRetryStrategy.java
 
b/paimon-api/src/test/java/org/apache/paimon/rest/TestExponentialHttpRequestRetryStrategy.java
index 9a743a93d9..cc4863f4e8 100644
--- 
a/paimon-api/src/test/java/org/apache/paimon/rest/TestExponentialHttpRequestRetryStrategy.java
+++ 
b/paimon-api/src/test/java/org/apache/paimon/rest/TestExponentialHttpRequestRetryStrategy.java
@@ -20,6 +20,7 @@ package org.apache.paimon.rest;
 
 import org.apache.hc.client5.http.HttpRequestRetryStrategy;
 import org.apache.hc.client5.http.classic.methods.HttpGet;
+import org.apache.hc.client5.http.protocol.HttpClientContext;
 import org.apache.hc.client5.http.utils.DateUtils;
 import org.apache.hc.core5.http.ConnectionClosedException;
 import org.apache.hc.core5.http.HttpHeaders;
@@ -220,4 +221,21 @@ public class TestExponentialHttpRequestRetryStrategy {
         BasicHttpResponse response = new BasicHttpResponse(statusCode, 
String.valueOf(statusCode));
         assertThat(retryStrategy.retryRequest(response, 3, null)).isFalse();
     }
+
+    @ParameterizedTest
+    @ValueSource(ints = {429, 503})
+    public void testRetryDoesNotHappenOnAnExchangeMarkedUnsafe(int statusCode) 
{
+        BasicHttpResponse response = new BasicHttpResponse(statusCode, 
String.valueOf(statusCode));
+
+        // The status says the request failed, not that the server never saw 
it: a 503 can come
+        // from an intermediary after the request already took effect.
+        assertThat(
+                        retryStrategy.retryRequest(
+                                response,
+                                1,
+                                
ExponentialHttpRequestRetryStrategy.retryUnsafeContext()))
+                .isFalse();
+        // Only the marked exchange loses the retry; a plain one keeps it.
+        assertThat(retryStrategy.retryRequest(response, 1, 
HttpClientContext.create())).isTrue();
+    }
 }

Reply via email to