This is an automated email from the ASF dual-hosted git repository.
huaxingao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg.git
The following commit(s) were added to refs/heads/main by this push:
new 7d6d369157 Core: Bound Idempotency-Key retries to the advertised key
lifetime (#18070)
7d6d369157 is described below
commit 7d6d369157f61be85add25aaf2dfcc78f456ccbc
Author: Sushant Raikar <[email protected]>
AuthorDate: Mon Sep 28 23:15:55 2026 -0700
Core: Bound Idempotency-Key retries to the advertised key lifetime (#18070)
Auto-retrying a keyed mutation could previously fire after the server's
advertised Idempotency-Key lifetime elapsed, at which point the server no
longer dedupes the key and could apply the mutation twice. This records when a
request is first sent (in HTTPClient.execute) and, for requests carrying an
Idempotency-Key, declines a retry once the elapsed time since the first attempt
plus the next retry interval would reach the advertised lifetime. The lifetime
from GET /v1/config is plumbe [...]
Per Yufei's review, the check compares against the upper bound of the next
retry interval (Retry-After verbatim, or exponential backoff plus its maximum
jitter) rather than a freshly sampled value, so a retry the check permits
cannot then sleep longer than checked and overshoot the window.
---
.../rest/ExponentialHttpRequestRetryStrategy.java | 130 +++++++++++----
.../java/org/apache/iceberg/rest/HTTPClient.java | 32 +++-
.../apache/iceberg/rest/RESTSessionCatalog.java | 7 +-
.../TestExponentialHttpRequestRetryStrategy.java | 182 +++++++++++++++++++++
.../org/apache/iceberg/rest/TestHTTPClient.java | 20 +++
5 files changed, 333 insertions(+), 38 deletions(-)
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 6ae57e6d2c..7356575ade 100644
---
a/core/src/main/java/org/apache/iceberg/rest/ExponentialHttpRequestRetryStrategy.java
+++
b/core/src/main/java/org/apache/iceberg/rest/ExponentialHttpRequestRetryStrategy.java
@@ -23,6 +23,7 @@ import java.io.InterruptedIOException;
import java.net.ConnectException;
import java.net.NoRouteToHostException;
import java.net.UnknownHostException;
+import java.time.Duration;
import java.time.Instant;
import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;
@@ -42,6 +43,8 @@ import org.apache.hc.core5.http.protocol.HttpCoreContext;
import org.apache.hc.core5.util.TimeValue;
import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
/**
* Defines an exponential HTTP request retry strategy and provides the same
characteristics as the
@@ -82,15 +85,26 @@ import
org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet;
* {@link #getRetryInterval(HttpResponse, int, HttpContext)} to achieve
exponential backoff.
*/
class ExponentialHttpRequestRetryStrategy implements HttpRequestRetryStrategy {
+ private static final Logger LOG =
+ LoggerFactory.getLogger(ExponentialHttpRequestRetryStrategy.class);
+
+ static final String FIRST_ATTEMPT_EPOCH = "first-attempt-epoch";
+
private final int maxRetries;
+ private final Duration keyLifetime;
private final Set<Class<? extends IOException>> nonRetriableExceptions;
private final Set<Integer> retriableCodes;
private final Set<Integer> idempotentRetriableCodes;
ExponentialHttpRequestRetryStrategy(int maximumRetries) {
+ this(maximumRetries, null);
+ }
+
+ ExponentialHttpRequestRetryStrategy(int maximumRetries, Duration
keyLifetime) {
Preconditions.checkArgument(
maximumRetries > 0, "Cannot set retries to %s, the value must be
positive", maximumRetries);
this.maxRetries = maximumRetries;
+ this.keyLifetime = keyLifetime;
this.retriableCodes = ImmutableSet.of(HttpStatus.SC_TOO_MANY_REQUESTS);
this.idempotentRetriableCodes =
ImmutableSet.of(
@@ -133,10 +147,11 @@ class ExponentialHttpRequestRetryStrategy implements
HttpRequestRetryStrategy {
return false;
}
- // 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);
+ if (!isSafeToRetry(request)) {
+ return false;
+ }
+
+ return !wouldExceedKeyLifetime(request, null, execCount, context);
}
@Override
@@ -154,51 +169,94 @@ class ExponentialHttpRequestRetryStrategy implements
HttpRequestRetryStrategy {
// - It's in a predefined list of retriable codes.
// - The request is idempotent, and the response code indicates a retry
is safe.
// - The response code is '503 Service Unavailable' and includes a
'Retry-After' header.
- return execCount <= maxRetries
- && (retriableCodes.contains(response.getCode())
- || shouldRetryIdempotent(request, response.getCode())
- || is503Retryable);
+ boolean shouldRetry =
+ execCount <= maxRetries
+ && (retriableCodes.contains(response.getCode())
+ || (isSafeToRetry(request) &&
idempotentRetriableCodes.contains(response.getCode()))
+ || is503Retryable);
+
+ if (!shouldRetry) {
+ return false;
+ }
+
+ return !wouldExceedKeyLifetime(request, response, execCount, context);
}
@Override
public TimeValue getRetryInterval(HttpResponse response, int execCount,
HttpContext context) {
- // a server may send a 429 / 503 with a Retry-After header
- // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
+ TimeValue retryAfter = retryAfter(response);
+ if (TimeValue.isPositive(retryAfter)) {
+ return retryAfter;
+ }
+ long delayMillis = nextBackoffMillis(execCount);
+ int jitter = ThreadLocalRandom.current().nextInt(Math.max(1, (int)
(delayMillis * 0.1)));
+ return TimeValue.ofMilliseconds(delayMillis + jitter);
+ }
+
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
(seconds or HTTP-date)
+ private static TimeValue retryAfter(HttpResponse response) {
Header header = response.getFirstHeader(HttpHeaders.RETRY_AFTER);
- TimeValue retryAfter = null;
- if (header != null) {
- String value = header.getValue();
- try {
- retryAfter = TimeValue.ofSeconds(Long.parseLong(value));
- } catch (NumberFormatException ignore) {
- Instant retryAfterDate = DateUtils.parseStandardDate(value);
- if (retryAfterDate != null) {
- retryAfter =
- TimeValue.ofMilliseconds(retryAfterDate.toEpochMilli() -
System.currentTimeMillis());
- }
- }
+ if (header == null) {
+ return null;
+ }
+ String value = header.getValue();
+ try {
+ return TimeValue.ofSeconds(Long.parseLong(value));
+ } catch (NumberFormatException ignore) {
+ Instant date = DateUtils.parseStandardDate(value);
+ return date == null
+ ? null
+ : TimeValue.ofMilliseconds(date.toEpochMilli() -
System.currentTimeMillis());
+ }
+ }
- if (TimeValue.isPositive(retryAfter)) {
- return retryAfter;
- }
+ // Upper bound of getRetryInterval: Retry-After verbatim (no jitter), else
backoff + max 10%
+ // jitter.
+ // The lifetime check uses this so a permitted retry can't then sleep longer
than checked.
+ private static long maxRetryIntervalMillis(HttpResponse response, int
execCount) {
+ TimeValue retryAfter = retryAfter(response);
+ if (TimeValue.isPositive(retryAfter)) {
+ return retryAfter.toMilliseconds();
}
+ long delayMillis = nextBackoffMillis(execCount);
+ return delayMillis + (long) (delayMillis * 0.1);
+ }
- int delayMillis = 1000 * (int) Math.min(Math.pow(2.0, (long) execCount -
1.0), 64.0);
- int jitter = ThreadLocalRandom.current().nextInt(Math.max(1, (int)
(delayMillis * 0.1)));
+ private static boolean isSafeToRetry(HttpRequest request) {
+ return request != null
+ && (Method.isIdempotent(request.getMethod())
+ || request.containsHeader(RESTUtil.IDEMPOTENCY_KEY_HEADER));
+ }
- return TimeValue.ofMilliseconds(delayMillis + jitter);
+ private static long nextBackoffMillis(int execCount) {
+ return 1000L * (long) Math.min(Math.pow(2.0, (long) execCount - 1.0),
64.0);
}
- private boolean shouldRetryIdempotent(HttpRequest request, int responseCode)
{
- if (request == null) {
+ private boolean wouldExceedKeyLifetime(
+ HttpRequest request, HttpResponse response, int execCount, HttpContext
context) {
+ if (keyLifetime == null
+ || context == null
+ || request == null
+ || !request.containsHeader(RESTUtil.IDEMPOTENCY_KEY_HEADER)) {
+ return false;
+ }
+
+ Object attr = context.getAttribute(FIRST_ATTEMPT_EPOCH);
+ if (!(attr instanceof Long)) {
+ LOG.warn(
+ "Idempotency-Key lifetime not enforced: first-attempt time not
recorded for {}",
+ request.getMethod());
return false;
}
- // 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);
+ long firstAttemptMillis = (Long) attr;
+ long now = System.currentTimeMillis();
+ long nextIntervalMillis =
+ response != null
+ ? maxRetryIntervalMillis(response, execCount)
+ : nextBackoffMillis(execCount);
+ boolean nextAttemptExceedsLifetime =
+ (now - firstAttemptMillis) + nextIntervalMillis >=
keyLifetime.toMillis();
+ return nextAttemptExceedsLifetime;
}
}
diff --git a/core/src/main/java/org/apache/iceberg/rest/HTTPClient.java
b/core/src/main/java/org/apache/iceberg/rest/HTTPClient.java
index e2f69c29b0..100232eb83 100644
--- a/core/src/main/java/org/apache/iceberg/rest/HTTPClient.java
+++ b/core/src/main/java/org/apache/iceberg/rest/HTTPClient.java
@@ -24,6 +24,8 @@ import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.format.DateTimeParseException;
import java.util.Map;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;
@@ -81,6 +83,7 @@ public class HTTPClient extends BaseHTTPClient {
static final String CLIENT_GIT_COMMIT_SHORT_HEADER =
"X-Client-Git-Commit-Short";
private static final String REST_MAX_RETRIES = "rest.client.max-retries";
+ static final String REST_IDEMPOTENCY_KEY_LIFETIME =
"rest.client.idempotency-key-lifetime";
static final String REST_MAX_CONNECTIONS = "rest.client.max-connections";
static final int REST_MAX_CONNECTIONS_DEFAULT = 100;
static final String REST_MAX_CONNECTIONS_PER_ROUTE =
"rest.client.connections-per-route";
@@ -125,7 +128,9 @@ public class HTTPClient extends BaseHTTPClient {
clientBuilder.setConnectionManager(connectionManager);
int maxRetries = PropertyUtil.propertyAsInt(properties, REST_MAX_RETRIES,
5);
- clientBuilder.setRetryStrategy(new
ExponentialHttpRequestRetryStrategy(maxRetries));
+ Duration keyLifetime =
parseKeyLifetime(properties.get(REST_IDEMPOTENCY_KEY_LIFETIME));
+ clientBuilder.setRetryStrategy(
+ new ExponentialHttpRequestRetryStrategy(maxRetries, keyLifetime));
String userAgent = PropertyUtil.propertyAsString(properties,
REST_USER_AGENT, null);
if (userAgent != null) {
@@ -164,6 +169,28 @@ public class HTTPClient extends BaseHTTPClient {
return new HTTPClient(this, session);
}
+ @VisibleForTesting
+ static Duration parseKeyLifetime(String lifetime) {
+ if (lifetime == null) {
+ return null;
+ }
+ try {
+ Duration parsed = Duration.parse(lifetime);
+ if (parsed.isZero() || parsed.isNegative()) {
+ // Non-positive window: never retry keyed requests (null would instead
disable the bound).
+ return Duration.ZERO;
+ }
+ if (parsed.compareTo(Duration.ofMillis(Long.MAX_VALUE)) > 0) {
+ LOG.warn("Ignoring out-of-range idempotency-key lifetime: {}",
lifetime);
+ return null;
+ }
+ return parsed;
+ } catch (DateTimeParseException | ArithmeticException e) {
+ LOG.warn("Ignoring malformed idempotency-key lifetime: {}", lifetime, e);
+ return null;
+ }
+ }
+
private static String extractResponseBodyAsString(ClassicHttpResponse
response) {
try {
if (response.getEntity() == null) {
@@ -317,6 +344,9 @@ public class HTTPClient extends BaseHTTPClient {
}
HttpContext context = HttpClientContext.create();
+ // Record first-send time so the retry strategy bounds keyed retries to
the key lifetime.
+ context.setAttribute(
+ ExponentialHttpRequestRetryStrategy.FIRST_ATTEMPT_EPOCH,
System.currentTimeMillis());
try {
return httpClient.execute(
request,
diff --git a/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java
b/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java
index 0b30ef9d88..663d28f6b8 100644
--- a/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java
+++ b/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java
@@ -215,13 +215,18 @@ public class RESTSessionCatalog extends
BaseViewSessionCatalog
}
// build the final configuration and set up the catalog's auth
- Map<String, String> mergedProps = config.merge(props);
+ ImmutableMap.Builder<String, String> mergedPropsBuilder =
+ ImmutableMap.<String, String>builder().putAll(config.merge(props));
// Enable Idempotency-Key header for mutation endpoints if the server
advertises support
if (config.idempotencyKeyLifetime() != null) {
this.mutationHeaders = RESTUtil::idempotencyHeaders;
+ mergedPropsBuilder.put(
+ HTTPClient.REST_IDEMPOTENCY_KEY_LIFETIME,
config.idempotencyKeyLifetime());
}
+ Map<String, String> mergedProps = mergedPropsBuilder.buildKeepingLast();
+
if (config.endpoints().isEmpty()) {
this.endpoints =
PropertyUtil.propertyAsBoolean(
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 7dcacc3a12..b4e4cd5676 100644
---
a/core/src/test/java/org/apache/iceberg/rest/TestExponentialHttpRequestRetryStrategy.java
+++
b/core/src/test/java/org/apache/iceberg/rest/TestExponentialHttpRequestRetryStrategy.java
@@ -27,6 +27,7 @@ import java.net.ConnectException;
import java.net.NoRouteToHostException;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
+import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import javax.net.ssl.SSLException;
@@ -249,4 +250,185 @@ public class TestExponentialHttpRequestRetryStrategy {
context.setRequest(new BasicHttpRequest("POST", "/"));
assertThat(retryStrategy.retryRequest(response, 3, context)).isFalse();
}
+
+ @Test
+ public void testKeyedRetryAbandonedWhenLifetimeExceeded() {
+ HttpRequestRetryStrategy strategy =
+ new ExponentialHttpRequestRetryStrategy(5, Duration.ofSeconds(1));
+ BasicHttpResponse response =
+ new BasicHttpResponse(HttpStatus.SC_INTERNAL_SERVER_ERROR, "error");
+ response.addHeader(new BasicHeader(HttpHeaders.RETRY_AFTER, "3600"));
+ HttpClientContext context = HttpClientContext.create();
+ BasicHttpRequest request = new BasicHttpRequest("POST", "/");
+ request.addHeader(RESTUtil.IDEMPOTENCY_KEY_HEADER, "key-abandon");
+ context.setRequest(request);
+ context.setAttribute(
+ ExponentialHttpRequestRetryStrategy.FIRST_ATTEMPT_EPOCH,
System.currentTimeMillis());
+ assertThat(strategy.retryRequest(response, 1, context)).isFalse();
+ }
+
+ @Test
+ public void testKeyedRetryContinuesWithinLifetime() {
+ HttpRequestRetryStrategy strategy =
+ new ExponentialHttpRequestRetryStrategy(5, Duration.ofHours(1));
+ BasicHttpResponse response =
+ new BasicHttpResponse(HttpStatus.SC_INTERNAL_SERVER_ERROR, "error");
+ response.addHeader(new BasicHeader(HttpHeaders.RETRY_AFTER, "1"));
+ HttpClientContext context = HttpClientContext.create();
+ BasicHttpRequest request = new BasicHttpRequest("POST", "/");
+ request.addHeader(RESTUtil.IDEMPOTENCY_KEY_HEADER, "key-continue");
+ context.setRequest(request);
+ context.setAttribute(
+ ExponentialHttpRequestRetryStrategy.FIRST_ATTEMPT_EPOCH,
System.currentTimeMillis());
+ assertThat(strategy.retryRequest(response, 1, context)).isTrue();
+ }
+
+ @Test
+ public void testKeyedNetworkExceptionRetryAbandonedWhenLifetimeExceeded() {
+ HttpRequestRetryStrategy strategy =
+ new ExponentialHttpRequestRetryStrategy(5, Duration.ofMillis(1));
+ HttpClientContext context = HttpClientContext.create();
+ BasicHttpRequest request = new BasicHttpRequest("POST", "/");
+ request.addHeader(RESTUtil.IDEMPOTENCY_KEY_HEADER, "key-net-abandon");
+ context.setRequest(request);
+ context.setAttribute(
+ ExponentialHttpRequestRetryStrategy.FIRST_ATTEMPT_EPOCH,
System.currentTimeMillis());
+ assertThat(strategy.retryRequest(request, new IOException("connection
reset"), 1, context))
+ .isFalse();
+ }
+
+ @Test
+ public void testKeyedNetworkExceptionRetryHonoredWithoutLifetime() {
+ HttpRequestRetryStrategy strategy = new
ExponentialHttpRequestRetryStrategy(5);
+ HttpClientContext context = HttpClientContext.create();
+ BasicHttpRequest request = new BasicHttpRequest("POST", "/");
+ request.addHeader(RESTUtil.IDEMPOTENCY_KEY_HEADER, "key-net-ok");
+ context.setRequest(request);
+ assertThat(strategy.retryRequest(request, new IOException("connection
reset"), 1, context))
+ .isTrue();
+ }
+
+ @Test
+ public void testIdempotentGetNotConstrainedByKeyLifetime() {
+ HttpRequestRetryStrategy strategy =
+ new ExponentialHttpRequestRetryStrategy(5, Duration.ofMillis(1));
+ BasicHttpResponse response =
+ new BasicHttpResponse(HttpStatus.SC_INTERNAL_SERVER_ERROR, "error");
+ response.addHeader(new BasicHeader(HttpHeaders.RETRY_AFTER, "3600"));
+ HttpClientContext context = HttpClientContext.create();
+ context.setRequest(new BasicHttpRequest("GET", "/"));
+ assertThat(strategy.retryRequest(response, 1, context)).isTrue();
+ }
+
+ @Test
+ public void testKeyedRetryHonorsRecordedFirstAttemptTime() {
+ HttpRequestRetryStrategy strategy =
+ new ExponentialHttpRequestRetryStrategy(5, Duration.ofMinutes(30));
+ BasicHttpResponse response =
+ new BasicHttpResponse(HttpStatus.SC_INTERNAL_SERVER_ERROR, "error");
+ response.addHeader(new BasicHeader(HttpHeaders.RETRY_AFTER, "1"));
+ HttpClientContext context = HttpClientContext.create();
+ BasicHttpRequest request = new BasicHttpRequest("POST", "/");
+ request.addHeader(RESTUtil.IDEMPOTENCY_KEY_HEADER, "key-recorded");
+ context.setRequest(request);
+ context.setAttribute(
+ ExponentialHttpRequestRetryStrategy.FIRST_ATTEMPT_EPOCH,
+ System.currentTimeMillis() - Duration.ofMinutes(31).toMillis());
+ assertThat(strategy.retryRequest(response, 1, context)).isFalse();
+ }
+
+ @Test
+ public void testKeyedRetryAbandonedOnceCumulativeElapsedExceedsLifetime()
+ throws InterruptedException {
+ // 2s lifetime, 1s Retry-After: 1st retry allowed, 2nd abandoned once
cumulative elapsed > 2s.
+ HttpRequestRetryStrategy strategy =
+ new ExponentialHttpRequestRetryStrategy(5, Duration.ofSeconds(2));
+ BasicHttpResponse response =
+ new BasicHttpResponse(HttpStatus.SC_INTERNAL_SERVER_ERROR, "error");
+ response.addHeader(new BasicHeader(HttpHeaders.RETRY_AFTER, "1"));
+ HttpClientContext context = HttpClientContext.create();
+ BasicHttpRequest request = new BasicHttpRequest("POST", "/");
+ request.addHeader(RESTUtil.IDEMPOTENCY_KEY_HEADER, "key-cumulative");
+ context.setRequest(request);
+ context.setAttribute(
+ ExponentialHttpRequestRetryStrategy.FIRST_ATTEMPT_EPOCH,
System.currentTimeMillis());
+
+ assertThat(strategy.retryRequest(response, 1, context)).isTrue();
+ Thread.sleep(1100);
+ assertThat(strategy.retryRequest(response, 2, context)).isFalse();
+ }
+
+ @Test
+ public void testKeyedRetryProceedsWhenFirstAttemptTimeNotRecorded() {
+ HttpRequestRetryStrategy strategy =
+ new ExponentialHttpRequestRetryStrategy(5, Duration.ofSeconds(1));
+ BasicHttpResponse response =
+ new BasicHttpResponse(HttpStatus.SC_INTERNAL_SERVER_ERROR, "error");
+ response.addHeader(new BasicHeader(HttpHeaders.RETRY_AFTER, "3600"));
+ HttpClientContext context = HttpClientContext.create();
+ BasicHttpRequest request = new BasicHttpRequest("POST", "/");
+ request.addHeader(RESTUtil.IDEMPOTENCY_KEY_HEADER, "key-unrecorded");
+ context.setRequest(request);
+ assertThat(strategy.retryRequest(response, 1, context)).isTrue();
+ }
+
+ @Test
+ public void testKeyedRetryAbandonedWhenExponentialIntervalExceedsLifetime() {
+ // No Retry-After -> interval falls back to exponential (>=1s), exceeding
the 1ms lifetime.
+ HttpRequestRetryStrategy strategy =
+ new ExponentialHttpRequestRetryStrategy(5, Duration.ofMillis(1));
+ BasicHttpResponse response =
+ new BasicHttpResponse(HttpStatus.SC_INTERNAL_SERVER_ERROR, "error");
+ HttpClientContext context = HttpClientContext.create();
+ BasicHttpRequest request = new BasicHttpRequest("POST", "/");
+ request.addHeader(RESTUtil.IDEMPOTENCY_KEY_HEADER, "key-exp");
+ context.setRequest(request);
+ context.setAttribute(
+ ExponentialHttpRequestRetryStrategy.FIRST_ATTEMPT_EPOCH,
System.currentTimeMillis());
+ assertThat(strategy.retryRequest(response, 1, context)).isFalse();
+ }
+
+ @Test
+ public void testKeyedRetryAlwaysAbandonedWithZeroLifetime() {
+ HttpRequestRetryStrategy strategy = new
ExponentialHttpRequestRetryStrategy(5, Duration.ZERO);
+ BasicHttpResponse response =
+ new BasicHttpResponse(HttpStatus.SC_INTERNAL_SERVER_ERROR, "error");
+ response.addHeader(new BasicHeader(HttpHeaders.RETRY_AFTER, "1"));
+ HttpClientContext context = HttpClientContext.create();
+ BasicHttpRequest request = new BasicHttpRequest("POST", "/");
+ request.addHeader(RESTUtil.IDEMPOTENCY_KEY_HEADER, "key-zero");
+ context.setRequest(request);
+ context.setAttribute(
+ ExponentialHttpRequestRetryStrategy.FIRST_ATTEMPT_EPOCH,
System.currentTimeMillis());
+ assertThat(strategy.retryRequest(response, 1, context)).isFalse();
+ }
+
+ @Test
+ public void testIdempotentGetStillRetriesWithZeroLifetime() {
+ HttpRequestRetryStrategy strategy = new
ExponentialHttpRequestRetryStrategy(5, Duration.ZERO);
+ BasicHttpResponse response =
+ new BasicHttpResponse(HttpStatus.SC_INTERNAL_SERVER_ERROR, "error");
+ response.addHeader(new BasicHeader(HttpHeaders.RETRY_AFTER, "1"));
+ HttpClientContext context = HttpClientContext.create();
+ context.setRequest(new BasicHttpRequest("GET", "/"));
+ assertThat(strategy.retryRequest(response, 1, context)).isTrue();
+ }
+
+ @Test
+ public void testKeyedRetryAbandonedWhenMaxJitterWouldExceedLifetime() {
+ // Lifetime sits between base backoff (1s) and base + max jitter (1.1s);
the check uses the
+ // upper
+ // bound, so the retry is abandoned even though a low-jitter sleep might
have fit.
+ HttpRequestRetryStrategy strategy =
+ new ExponentialHttpRequestRetryStrategy(5, Duration.ofMillis(1050));
+ BasicHttpResponse response =
+ new BasicHttpResponse(HttpStatus.SC_INTERNAL_SERVER_ERROR, "error");
+ HttpClientContext context = HttpClientContext.create();
+ BasicHttpRequest request = new BasicHttpRequest("POST", "/");
+ request.addHeader(RESTUtil.IDEMPOTENCY_KEY_HEADER, "key-maxjitter");
+ context.setRequest(request);
+ context.setAttribute(
+ ExponentialHttpRequestRetryStrategy.FIRST_ATTEMPT_EPOCH,
System.currentTimeMillis());
+ assertThat(strategy.retryRequest(response, 1, context)).isFalse();
+ }
}
diff --git a/core/src/test/java/org/apache/iceberg/rest/TestHTTPClient.java
b/core/src/test/java/org/apache/iceberg/rest/TestHTTPClient.java
index 14d898e432..2c058ffc48 100644
--- a/core/src/test/java/org/apache/iceberg/rest/TestHTTPClient.java
+++ b/core/src/test/java/org/apache/iceberg/rest/TestHTTPClient.java
@@ -38,6 +38,7 @@ import java.net.SocketTimeoutException;
import java.nio.file.Path;
import java.security.KeyStore;
import java.security.cert.CertificateException;
+import java.time.Duration;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
@@ -71,6 +72,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockserver.configuration.Configuration;
import org.mockserver.integration.ClientAndServer;
@@ -614,6 +616,24 @@ public class TestHTTPClient {
assertThat(responseHeaders.get().get("etag")).isEqualTo("W/\"1\"");
}
+ @ParameterizedTest
+ @NullSource
+ @ValueSource(strings = {"PT9999999999999H", "PT99999999999999999999H",
"garbage", "30m", ""})
+ public void invalidIdempotencyKeyLifetimeIsIgnored(String value) {
+ assertThat(HTTPClient.parseKeyLifetime(value)).isNull();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"PT0S", "PT-5M", "-PT5M"})
+ public void nonPositiveIdempotencyKeyLifetimeReturnsZero(String value) {
+ assertThat(HTTPClient.parseKeyLifetime(value)).isEqualTo(Duration.ZERO);
+ }
+
+ @Test
+ public void validIdempotencyKeyLifetimeIsParsed() {
+
assertThat(HTTPClient.parseKeyLifetime("PT30M")).isEqualTo(Duration.ofMinutes(30));
+ }
+
public static void testHttpMethodOnSuccess(HttpMethod method) throws
JsonProcessingException {
Item body = new Item(0L, "hank");
int statusCode = 200;