diff --git a/core/src/main/java/org/apache/iceberg/rest/ExponentialHttpRequestRetryStrategy.java b/core/src/main/java/org/apache/iceberg/rest/ExponentialHttpRequestRetryStrategy.java index e9591585d523..6ae57e6d2c0e 100644 --- a/core/src/main/java/org/apache/iceberg/rest/ExponentialHttpRequestRetryStrategy.java +++ b/core/src/main/java/org/apache/iceberg/rest/ExponentialHttpRequestRetryStrategy.java @@ -74,7 +74,10 @@ *
Most code and behavior is taken from {@link
* org.apache.hc.client5.http.impl.DefaultHttpRequestRetryStrategy}, with minor modifications to
* {@link #getRetryInterval(HttpResponse, int, HttpContext)} to achieve exponential backoff.
*/
@@ -130,8 +133,10 @@ public boolean retryRequest(
return false;
}
- // Retry if the request is considered idempotent
- return Method.isIdempotent(request.getMethod());
+ // Retry if the request is idempotent, or carries an Idempotency-Key (server guarantees safe
+ // retry)
+ return Method.isIdempotent(request.getMethod())
+ || request.containsHeader(RESTUtil.IDEMPOTENCY_KEY_HEADER);
}
@Override
@@ -189,8 +194,11 @@ private boolean shouldRetryIdempotent(HttpRequest request, int responseCode) {
return false;
}
- // Check if the request is idempotent
- return Method.isIdempotent(request.getMethod())
- && idempotentRetriableCodes.contains(responseCode);
+ // A request is retry-safe if its HTTP method is idempotent or it carries an Idempotency-Key
+ // header (which lets the server replay a finalized result on retry).
+ boolean retrySafe =
+ Method.isIdempotent(request.getMethod())
+ || request.containsHeader(RESTUtil.IDEMPOTENCY_KEY_HEADER);
+ return retrySafe && idempotentRetriableCodes.contains(responseCode);
}
}
diff --git a/core/src/test/java/org/apache/iceberg/rest/TestExponentialHttpRequestRetryStrategy.java b/core/src/test/java/org/apache/iceberg/rest/TestExponentialHttpRequestRetryStrategy.java
index e118427ee5df..7dcacc3a1216 100644
--- a/core/src/test/java/org/apache/iceberg/rest/TestExponentialHttpRequestRetryStrategy.java
+++ b/core/src/test/java/org/apache/iceberg/rest/TestExponentialHttpRequestRetryStrategy.java
@@ -229,4 +229,24 @@ public void testRetryHappensWithIdempotentMethods(int statusCode) {
context.setRequest(new BasicHttpRequest("GET", "/"));
assertThat(retryStrategy.retryRequest(response, 3, context)).isTrue();
}
+
+ @ParameterizedTest
+ @ValueSource(ints = {429, 503, 500, 502, 504, 408})
+ public void testRetryHappensForNonIdempotentMethodWithIdempotencyKey(int statusCode) {
+ BasicHttpResponse response = new BasicHttpResponse(statusCode, String.valueOf(statusCode));
+ HttpClientContext context = HttpClientContext.create();
+ BasicHttpRequest request = new BasicHttpRequest("POST", "/");
+ request.addHeader(RESTUtil.IDEMPOTENCY_KEY_HEADER, "017f22e2-79b0-7cc3-98c4-dc0c0c07398f");
+ context.setRequest(request);
+ assertThat(retryStrategy.retryRequest(response, 3, context)).isTrue();
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {503, 500, 502, 504, 408})
+ public void testRetryDoesNotHappenForNonIdempotentMethodWithoutIdempotencyKey(int statusCode) {
+ BasicHttpResponse response = new BasicHttpResponse(statusCode, String.valueOf(statusCode));
+ HttpClientContext context = HttpClientContext.create();
+ context.setRequest(new BasicHttpRequest("POST", "/"));
+ assertThat(retryStrategy.retryRequest(response, 3, context)).isFalse();
+ }
}
diff --git a/core/src/test/java/org/apache/iceberg/rest/TestRESTCatalog.java b/core/src/test/java/org/apache/iceberg/rest/TestRESTCatalog.java
index 043ded09d413..d48ac5078100 100644
--- a/core/src/test/java/org/apache/iceberg/rest/TestRESTCatalog.java
+++ b/core/src/test/java/org/apache/iceberg/rest/TestRESTCatalog.java
@@ -48,6 +48,7 @@
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
+import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
@@ -166,6 +167,8 @@ private static class HeaderValidatingAdapter extends RESTCatalogAdapter {
private final HTTPHeaders contextHeaders;
private final java.util.concurrent.ConcurrentMap