diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/TooEarlyRetryStrategy.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/TooEarlyRetryStrategy.java
new file mode 100644
index 0000000000..5ee3cd91ab
--- /dev/null
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/TooEarlyRetryStrategy.java
@@ -0,0 +1,258 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.apache.hc.client5.http.HttpRequestRetryStrategy;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.hc.core5.http.HttpEntityContainer;
+import org.apache.hc.core5.http.HttpRequest;
+import org.apache.hc.core5.http.HttpResponse;
+import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.Method;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.http.protocol.HttpCoreContext;
+import org.apache.hc.core5.util.Args;
+import org.apache.hc.core5.util.TimeValue;
+
+/**
+ * Retry policy for RFC 8470 (Using Early Data in HTTP).
+ *
+ * This strategy allows a single automatic retry on {@code 425 Too Early}
+ * (and, optionally, on {@code 429} and {@code 503} honoring {@code Retry-After})
+ * for requests that are considered replay-safe:
+ * idempotent methods and, when present, repeatable entities.
+ *
+ *
+ * Notes
+ *
+ * - On {@code 425}, the context attribute
+ * {@link #DISABLE_EARLY_DATA_ATTR} is set to {@code Boolean.TRUE}
+ * so a TLS layer with 0-RTT support can avoid early data on the retry.
+ * - This class is thread-safe and can be reused across clients.
+ *
+ *
+ * @since 5.6
+ */
+public final class TooEarlyRetryStrategy implements HttpRequestRetryStrategy {
+
+ /**
+ * Context attribute key used to signal the transport/TLS layer that the next attempt
+ * must not use TLS 0-RTT early data. Implementations that support early data may
+ * check this flag and force a full handshake on retry.
+ */
+ public static final String DISABLE_EARLY_DATA_ATTR = "http.client.tls.early_data.disable";
+
+ private final int maxRetries;
+ private final boolean include429and503;
+ private final HttpRequestRetryStrategy delegateForExceptions; // optional, may be null
+
+ /**
+ * Creates a strategy that retries once on {@code 425 Too Early}.
+ *
+ * When {@code include429and503} is {@code true}, the same rules are also
+ * applied to {@code 429 Too Many Requests} and {@code 503 Service Unavailable},
+ * honoring {@code Retry-After} when present.
+ *
+ *
+ * @param include429and503 whether to also retry 429/503
+ * @since 5.6
+ */
+ public TooEarlyRetryStrategy(final boolean include429and503) {
+ this(1, include429and503, null);
+ }
+
+ /**
+ * Creates a strategy with custom limits and optional delegation for I/O exception retries.
+ *
+ * @param maxRetries maximum retry attempts for eligible status codes (recommended: {@code 1})
+ * @param include429and503 whether to also retry 429/503
+ * @param delegateForExceptions optional delegate to handle I/O exception retries; may be {@code null}
+ * @since 5.6
+ */
+ public TooEarlyRetryStrategy(
+ final int maxRetries,
+ final boolean include429and503,
+ final HttpRequestRetryStrategy delegateForExceptions) {
+ this.maxRetries = Args.positive(maxRetries, "maxRetries");
+ this.include429and503 = include429and503;
+ this.delegateForExceptions = delegateForExceptions;
+ }
+
+ /**
+ * Delegates I/O exception retry decisions to {@code delegateForExceptions} if provided;
+ * otherwise returns {@code false}.
+ *
+ * @param request the original request
+ * @param exception I/O exception that occurred
+ * @param execCount execution count (including the initial attempt)
+ * @param context HTTP context
+ * @return {@code true} to retry, {@code false} otherwise
+ * @since 5.6
+ */
+ @Override
+ public boolean retryRequest(
+ final HttpRequest request,
+ final IOException exception,
+ final int execCount,
+ final HttpContext context) {
+ return delegateForExceptions != null
+ && delegateForExceptions.retryRequest(request, exception, execCount, context);
+ }
+
+ /**
+ * Decides status-based retries for {@code 425} (and optionally {@code 429/503}).
+ *
+ * Retries only when:
+ *
+ *
+ * - {@code execCount} ≤ {@code maxRetries},
+ * - the original method is idempotent, and
+ * - any request entity is {@linkplain HttpEntity#isRepeatable() repeatable}.
+ *
+ *
+ * On {@code 425}, sets {@link #DISABLE_EARLY_DATA_ATTR} to {@code Boolean.TRUE}
+ * in the provided {@link HttpContext}.
+ *
+ *
+ * @param response the response received
+ * @param execCount execution count (including the initial attempt)
+ * @param context HTTP context (used to obtain the original request)
+ * @return {@code true} if the request should be retried, {@code false} otherwise
+ * @since 5.6
+ */
+ @Override
+ public boolean retryRequest(
+ final HttpResponse response,
+ final int execCount,
+ final HttpContext context) {
+
+ final int code = response.getCode();
+ final boolean eligible =
+ code == HttpStatus.SC_TOO_EARLY || include429and503 && (code == HttpStatus.SC_TOO_MANY_REQUESTS
+ || code == HttpStatus.SC_SERVICE_UNAVAILABLE);
+
+ if (!eligible || execCount > maxRetries) {
+ return false;
+ }
+
+ final HttpRequest original = HttpCoreContext.cast(context).getRequest();
+ if (original == null) {
+ return false;
+ }
+
+ if (!Method.normalizedValueOf(original.getMethod()).isIdempotent()) {
+ return false;
+ }
+
+ // Require repeatable entity when present (classic requests expose it via HttpEntityContainer).
+ if (original instanceof HttpEntityContainer) {
+ final HttpEntity entity = ((HttpEntityContainer) original).getEntity();
+ if (entity != null && !entity.isRepeatable()) {
+ return false;
+ }
+ }
+
+ if (code == HttpStatus.SC_TOO_EARLY) {
+ context.setAttribute(DISABLE_EARLY_DATA_ATTR, Boolean.TRUE);
+ }
+
+ return true;
+ }
+
+ /**
+ * Computes the back-off interval from {@code Retry-After}, when present, for
+ * eligible status codes.
+ *
+ * Supports both delta-seconds and HTTP-date (RFC 1123) formats.
+ * Unparseable values and past dates yield {@link TimeValue#ZERO_MILLISECONDS}.
+ *
+ *
+ * @param response the response
+ * @param execCount execution count (including the initial attempt)
+ * @param context HTTP context (unused)
+ * @return a {@link TimeValue} to wait before retrying; {@code ZERO_MILLISECONDS} if none
+ * @since 5.6
+ */
+ @Override
+ public TimeValue getRetryInterval(
+ final HttpResponse response,
+ final int execCount,
+ final HttpContext context) {
+
+ final int code = response.getCode();
+ final boolean eligible =
+ code == HttpStatus.SC_TOO_EARLY || include429and503 && (code == HttpStatus.SC_TOO_MANY_REQUESTS
+ || code == HttpStatus.SC_SERVICE_UNAVAILABLE);
+
+ if (!eligible) {
+ return TimeValue.ZERO_MILLISECONDS;
+ }
+
+ final Header h = response.getFirstHeader("Retry-After");
+ if (h == null) {
+ return TimeValue.ZERO_MILLISECONDS;
+ }
+
+ final String v = h.getValue().trim();
+
+ // 1) delta-seconds
+ try {
+ final long seconds = Long.parseLong(v);
+ if (seconds >= 0L) {
+ return TimeValue.ofSeconds(seconds);
+ }
+ } catch (final NumberFormatException ignore) {
+ // fall through to HTTP-date
+ }
+
+ // 2) HTTP-date (RFC 1123)
+ try {
+ final ZonedDateTime when = ZonedDateTime.parse(v, DateTimeFormatter.RFC_1123_DATE_TIME);
+ final long millis = when.toInstant().toEpochMilli() - Instant.now().toEpochMilli();
+ return millis > 0L ? TimeValue.ofMilliseconds(millis) : TimeValue.ZERO_MILLISECONDS;
+ } catch (final DateTimeParseException ignore) {
+ return TimeValue.ZERO_MILLISECONDS;
+ }
+ }
+
+ @Override
+ public String toString() {
+ return "TooEarlyRetryStrategy(maxRetries=" + maxRetries +
+ ", include429and503=" + include429and503 + ')';
+ }
+}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/TooEarlyStatusRetryExec.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/TooEarlyStatusRetryExec.java
new file mode 100644
index 0000000000..a890541ba9
--- /dev/null
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/TooEarlyStatusRetryExec.java
@@ -0,0 +1,114 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.apache.hc.client5.http.classic.ExecChain;
+import org.apache.hc.client5.http.classic.ExecChainHandler;
+import org.apache.hc.core5.http.ClassicHttpRequest;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.Method;
+import org.apache.hc.core5.http.io.entity.EntityUtils;
+
+/**
+ * Classic exec-chain interceptor that re-executes the request exactly once on
+ * {@code 425 Too Early} (and optionally on {@code 429}/{@code 503})
+ * for idempotent requests with repeatable entities.
+ *
+ * @since 5.6
+ */
+public final class TooEarlyStatusRetryExec implements ExecChainHandler {
+
+ private static final String RETRIED_ATTR = "http.client.too_early.retried";
+
+ private final boolean include429and503;
+
+ public TooEarlyStatusRetryExec(final boolean include429and503) {
+ this.include429and503 = include429and503;
+ }
+
+ @Override
+ public ClassicHttpResponse execute(
+ final ClassicHttpRequest request,
+ final ExecChain.Scope scope,
+ final ExecChain chain) throws IOException, HttpException {
+
+ final ClassicHttpResponse response = chain.proceed(request, scope);
+
+ final int code = response.getCode();
+ final boolean eligible = code == HttpStatus.SC_TOO_EARLY || include429and503 && (code == HttpStatus.SC_TOO_MANY_REQUESTS
+ || code == HttpStatus.SC_SERVICE_UNAVAILABLE);
+
+ final boolean alreadyRetried = Boolean.TRUE.equals(scope.clientContext.getAttribute(RETRIED_ATTR));
+ final boolean idempotent = Method.normalizedValueOf(request.getMethod()).isIdempotent();
+ final HttpEntity reqEntity = request.getEntity();
+ final boolean repeatable = reqEntity == null || reqEntity.isRepeatable();
+
+ if (eligible && !alreadyRetried && idempotent && repeatable) {
+ // RFC 8470: tell TLS/transport to avoid early data on the retry
+ if (code == HttpStatus.SC_TOO_EARLY) {
+ scope.clientContext.setAttribute(
+ TooEarlyRetryStrategy.DISABLE_EARLY_DATA_ATTR, Boolean.TRUE);
+ }
+ scope.clientContext.setAttribute(RETRIED_ATTR, Boolean.TRUE);
+
+ // Drain & close first response (ignore errors – we discard it anyway)
+ try {
+ final HttpEntity respEntity = response.getEntity();
+ if (respEntity != null) {
+ EntityUtils.consume(respEntity);
+ }
+ } catch (final Exception ignore) {
+ }
+ try {
+ response.close();
+ } catch (final Exception ignore) {
+ }
+
+ // The first exchange may have released the endpoint; reacquire before retrying.
+ try {
+ scope.execRuntime.discardEndpoint(); // safe even if none is held
+ } catch (final Exception ignore) {
+ }
+ // 5.6 signature: (String id, HttpRoute route, Object state, HttpClientContext ctx)
+ scope.execRuntime.acquireEndpoint(null, scope.route, null, scope.clientContext);
+
+ // Retry once
+ return chain.proceed(request, scope);
+ }
+
+ return response;
+ }
+}
\ No newline at end of file
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClients.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClients.java
index 3ef487a8e6..a4f498b3a0 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClients.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClients.java
@@ -33,6 +33,7 @@
import org.apache.hc.client5.http.config.TlsConfig;
import org.apache.hc.client5.http.impl.DefaultClientConnectionReuseStrategy;
import org.apache.hc.client5.http.impl.DefaultSchemePortResolver;
+import org.apache.hc.client5.http.impl.TooEarlyRetryStrategy;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.compat.ClassicToAsyncAdaptor;
import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder;
@@ -119,6 +120,25 @@ public static CloseableHttpAsyncClient createHttp2System() {
return H2AsyncClientBuilder.create().useSystemProperties().build();
}
+ /**
+ * Build an async client with RFC 8470 handling baked in:
+ *
+ * - Retry once on {@code 425 Too Early}.
+ * - Also retry {@code 429} / {@code 503} respecting {@code Retry-After}.
+ * - Retries only on idempotent methods (producer repeatability is assumed for these).
+ *
+ */
+ public static CloseableHttpAsyncClient createDefaultTooEarlyAware() {
+ return customTooEarlyAware(true).build();
+ }
+
+ /**
+ * Same as {@link #createDefaultTooEarlyAware()} but also uses system properties.
+ */
+ public static CloseableHttpAsyncClient createSystemTooEarlyAware() {
+ return customTooEarlyAware(true).useSystemProperties().build();
+ }
+
private static HttpProcessor createMinimalProtocolProcessor() {
return new DefaultHttpProcessor(
new H2RequestTargetHost(),
@@ -375,4 +395,14 @@ public static CloseableHttpClient classic(final CloseableHttpAsyncClient client,
return new ClassicToAsyncAdaptor(client, operationTimeout);
}
+ /**
+ * Create a new {@link HttpAsyncClientBuilder} that preconfigures {@link TooEarlyRetryStrategy}.
+ *
+ * @param include429and503 when {@code true}, also retry {@code 429} and {@code 503} with {@code Retry-After}.
+ */
+ public static HttpAsyncClientBuilder customTooEarlyAware(final boolean include429and503) {
+ return HttpAsyncClientBuilder.create()
+ .setRetryStrategy(new TooEarlyRetryStrategy(include429and503));
+ }
+
}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/TooEarlyStatusRetryAsyncExec.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/TooEarlyStatusRetryAsyncExec.java
new file mode 100644
index 0000000000..3466a648e3
--- /dev/null
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/TooEarlyStatusRetryAsyncExec.java
@@ -0,0 +1,183 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+
+package org.apache.hc.client5.http.impl.async;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.apache.hc.client5.http.async.AsyncExecCallback;
+import org.apache.hc.client5.http.async.AsyncExecChain;
+import org.apache.hc.client5.http.async.AsyncExecChain.Scope;
+import org.apache.hc.client5.http.async.AsyncExecChainHandler;
+import org.apache.hc.client5.http.impl.TooEarlyRetryStrategy;
+import org.apache.hc.core5.http.EntityDetails;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.HttpRequest;
+import org.apache.hc.core5.http.HttpResponse;
+import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.Method;
+import org.apache.hc.core5.http.nio.AsyncDataConsumer;
+import org.apache.hc.core5.http.nio.AsyncEntityProducer;
+import org.apache.hc.core5.http.nio.CapacityChannel;
+
+/**
+ * Async exec-chain interceptor that re-executes the request exactly once on
+ * {@code 425 Too Early} (and optionally on {@code 429}/{@code 503})
+ * for idempotent requests with repeatable producers.
+ *
+ * @since 5.6
+ */
+public final class TooEarlyStatusRetryAsyncExec implements AsyncExecChainHandler {
+
+ private static final String RETRIED_ATTR = "http.client.too_early.retried";
+
+ private final boolean include429and503;
+
+ public TooEarlyStatusRetryAsyncExec(final boolean include429and503) {
+ this.include429and503 = include429and503;
+ }
+
+ @Override
+ public void execute(
+ final HttpRequest request,
+ final AsyncEntityProducer entityProducer,
+ final Scope scope,
+ final AsyncExecChain chain,
+ final AsyncExecCallback callback) throws HttpException, IOException {
+
+ chain.proceed(request, entityProducer, scope, new AsyncExecCallback() {
+
+ /** True once we have decided to retry and scheduled the second proceed(). */
+ private volatile boolean retryScheduled;
+
+ @Override
+ public AsyncDataConsumer handleResponse(
+ final HttpResponse response,
+ final EntityDetails entityDetails) throws HttpException, IOException {
+
+ final int code = response.getCode();
+ final boolean eligible = code == HttpStatus.SC_TOO_EARLY ||
+ include429and503 && (code == HttpStatus.SC_TOO_MANY_REQUESTS
+ || code == HttpStatus.SC_SERVICE_UNAVAILABLE);
+
+ final boolean alreadyRetried =
+ Boolean.TRUE.equals(scope.clientContext.getAttribute(RETRIED_ATTR));
+ final boolean idempotent = Method.normalizedValueOf(request.getMethod()).isIdempotent();
+ final boolean repeatable = entityProducer == null || entityProducer.isRepeatable();
+
+ if (eligible && !alreadyRetried && idempotent && repeatable) {
+ scope.clientContext.setAttribute(RETRIED_ATTR, Boolean.TRUE);
+ if (code == HttpStatus.SC_TOO_EARLY) {
+ scope.clientContext.setAttribute(
+ TooEarlyRetryStrategy.DISABLE_EARLY_DATA_ATTR, Boolean.TRUE);
+ }
+
+ // If there is no response body, retry immediately.
+ if (entityDetails == null) {
+ retryScheduled = true;
+ chain.proceed(request, entityProducer, scope, callback);
+ // No body expected; return a no-op consumer just in case.
+ return new AsyncDataConsumer() {
+ @Override
+ public void updateCapacity(final CapacityChannel c) throws IOException {
+ }
+
+ @Override
+ public void consume(final ByteBuffer src) throws IOException {
+ src.position(src.limit());
+ }
+
+ @Override
+ public void streamEnd(final List extends Header> trailers) throws HttpException, IOException {
+ }
+
+
+ @Override
+ public void releaseResources() {
+ }
+ };
+ }
+
+ // Otherwise, discard the body and retry on end-of-stream.
+ return new AsyncDataConsumer() {
+ @Override
+ public void updateCapacity(final CapacityChannel capacityChannel) throws IOException {
+ capacityChannel.update(Integer.MAX_VALUE);
+ }
+
+ @Override
+ public void consume(final ByteBuffer src) throws IOException {
+ src.position(src.limit());
+ }
+
+ @Override
+ public void streamEnd(final List extends Header> trailers) throws HttpException, IOException {
+ retryScheduled = true;
+ chain.proceed(request, entityProducer, scope, callback);
+ }
+
+
+ @Override
+ public void releaseResources() { /* no-op */ }
+ };
+ }
+
+ // Not retrying: delegate to the original callback.
+ return callback.handleResponse(response, entityDetails);
+ }
+
+ @Override
+ public void handleInformationResponse(final HttpResponse response) throws HttpException, IOException {
+ // pass through
+ callback.handleInformationResponse(response);
+ }
+
+ @Override
+ public void completed() {
+ // If the first exchange triggered a retry, ignore its completion:
+ if (!retryScheduled) {
+ callback.completed();
+ }
+ }
+
+ @Override
+ public void failed(final Exception cause) {
+ // If we already scheduled a retry, ignore failure from the first exchange.
+ if (!retryScheduled) {
+ callback.failed(cause);
+ }
+ }
+ });
+ }
+}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClients.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClients.java
index 55ccb58309..5d09161c95 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClients.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClients.java
@@ -27,6 +27,7 @@
package org.apache.hc.client5.http.impl.classic;
+import org.apache.hc.client5.http.impl.TooEarlyRetryStrategy;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
import org.apache.hc.client5.http.io.HttpClientConnectionManager;
@@ -81,4 +82,34 @@ public static MinimalHttpClient createMinimal(final HttpClientConnectionManager
return new MinimalHttpClient(connManager);
}
+ /**
+ * Create a new {@link HttpClientBuilder} that preconfigures {@link TooEarlyRetryStrategy}.
+ *
+ * @param include429and503 when {@code true}, also retry {@code 429} and {@code 503} with {@code Retry-After}.
+ * @since 5.6
+ */
+ public static HttpClientBuilder customTooEarlyAware(final boolean include429and503) {
+ return HttpClientBuilder.create()
+ .setRetryStrategy(new TooEarlyRetryStrategy(include429and503));
+ }
+
+ /**
+ * Build a client with RFC 8470 handling baked in:
+ *
+ * - Retry once on {@code 425 Too Early}.
+ * - Also retry {@code 429} / {@code 503} respecting {@code Retry-After}.
+ * - Retries only on idempotent methods and repeatable entities.
+ *
+ * @since 5.6
+ */
+ public static CloseableHttpClient createDefaultTooEarlyAware() {
+ return customTooEarlyAware(true).build();
+ }
+
+ /**
+ * Same as {@link #createDefaultTooEarlyAware()} but also uses system properties.
+ */
+ public static CloseableHttpClient createSystemTooEarlyAware() {
+ return customTooEarlyAware(true).useSystemProperties().build();
+ }
}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/TooEarlyRetryStrategyTest.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/TooEarlyRetryStrategyTest.java
new file mode 100644
index 0000000000..c4a9171b80
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/TooEarlyRetryStrategyTest.java
@@ -0,0 +1,148 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyInt;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+
+import org.apache.hc.client5.http.HttpRequestRetryStrategy;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpRequest;
+import org.apache.hc.core5.http.HttpResponse;
+import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.http.protocol.HttpCoreContext;
+import org.apache.hc.core5.util.TimeValue;
+import org.junit.jupiter.api.Test;
+
+class TooEarlyRetryStrategyTest {
+
+ @Test
+ void retriesOnceOn425ForIdempotent() {
+ final TooEarlyRetryStrategy strat = new TooEarlyRetryStrategy(true);
+
+ final HttpResponse resp425 = mock(HttpResponse.class);
+ when(resp425.getCode()).thenReturn(HttpStatus.SC_TOO_EARLY);
+
+ final HttpRequest req = mock(HttpRequest.class);
+ when(req.getMethod()).thenReturn("GET");
+
+ final HttpCoreContext ctx = HttpCoreContext.create();
+ ctx.setRequest(req);
+
+ assertTrue(strat.retryRequest(resp425, 1, ctx));
+ // verify flag set
+ assertEquals(Boolean.TRUE,
+ ctx.getAttribute(TooEarlyRetryStrategy.DISABLE_EARLY_DATA_ATTR));
+ }
+
+
+ @Test
+ void doesNotRetryNonIdempotent() {
+ final TooEarlyRetryStrategy strat = new TooEarlyRetryStrategy(true);
+
+ final HttpResponse resp425 = mock(HttpResponse.class);
+ when(resp425.getCode()).thenReturn(HttpStatus.SC_TOO_EARLY);
+
+ final HttpRequest req = mock(HttpRequest.class);
+ when(req.getMethod()).thenReturn("POST");
+
+ final HttpContext ctx = mock(HttpContext.class);
+ when(ctx.getAttribute(HttpCoreContext.HTTP_REQUEST)).thenReturn(req);
+
+ assertFalse(strat.retryRequest(resp425, 1, ctx));
+ }
+
+ @Test
+ void maxRetriesRespected() {
+ final TooEarlyRetryStrategy strat = new TooEarlyRetryStrategy(1, true, (HttpRequestRetryStrategy) null);
+
+ final HttpResponse resp425 = mock(HttpResponse.class);
+ when(resp425.getCode()).thenReturn(HttpStatus.SC_TOO_EARLY);
+
+ final HttpRequest req = mock(HttpRequest.class);
+ when(req.getMethod()).thenReturn("GET");
+
+ final HttpContext ctx = mock(HttpContext.class);
+ when(ctx.getAttribute(HttpCoreContext.HTTP_REQUEST)).thenReturn(req);
+
+ assertFalse(strat.retryRequest(resp425, 2, ctx), "execCount > maxRetries");
+ }
+
+ @Test
+ void retryAfterDeltaSeconds() {
+ final TooEarlyRetryStrategy strat = new TooEarlyRetryStrategy(true);
+
+ final HttpResponse resp = mock(HttpResponse.class);
+ when(resp.getCode()).thenReturn(HttpStatus.SC_TOO_MANY_REQUESTS);
+ final Header h = mock(Header.class);
+ when(h.getValue()).thenReturn("3");
+ when(resp.getFirstHeader("Retry-After")).thenReturn(h);
+
+ final TimeValue tv = strat.getRetryInterval(resp, 1, mock(HttpContext.class));
+ assertEquals(3000L, tv.toMilliseconds());
+ }
+
+ @Test
+ void retryAfterHttpDate() {
+ final TooEarlyRetryStrategy strat = new TooEarlyRetryStrategy(true);
+
+ final ZonedDateTime future = ZonedDateTime.now().plusSeconds(2);
+ final String httpDate = future.format(DateTimeFormatter.RFC_1123_DATE_TIME);
+
+ final HttpResponse resp = mock(HttpResponse.class);
+ when(resp.getCode()).thenReturn(HttpStatus.SC_SERVICE_UNAVAILABLE);
+ final Header h = mock(Header.class);
+ when(h.getValue()).thenReturn(httpDate);
+ when(resp.getFirstHeader("Retry-After")).thenReturn(h);
+
+ final TimeValue tv = strat.getRetryInterval(resp, 1, mock(HttpContext.class));
+ assertTrue(tv.toMilliseconds() >= 1000L, "expected ~2s backoff");
+ }
+
+ @Test
+ void delegateForExceptionsHonored() {
+ final HttpRequestRetryStrategy delegate = mock(HttpRequestRetryStrategy.class);
+ when(delegate.retryRequest(any(HttpRequest.class), any(IOException.class), anyInt(), any(HttpContext.class)))
+ .thenReturn(true);
+
+ final TooEarlyRetryStrategy strat = new TooEarlyRetryStrategy(1, true, delegate);
+
+ assertTrue(strat.retryRequest(mock(HttpRequest.class), new IOException("boom"), 1, mock(HttpContext.class)));
+ verify(delegate).retryRequest(any(HttpRequest.class), any(IOException.class), anyInt(), any(HttpContext.class));
+ }
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TooEarlyStatusRetryAsyncExecIT.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TooEarlyStatusRetryAsyncExecIT.java
new file mode 100644
index 0000000000..c7740adb31
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TooEarlyStatusRetryAsyncExecIT.java
@@ -0,0 +1,249 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl.async;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+
+import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
+import org.apache.hc.client5.http.async.methods.SimpleHttpResponse;
+import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
+import org.apache.hc.client5.http.impl.TooEarlyRetryStrategy;
+import org.apache.hc.core5.concurrent.FutureCallback;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.Execution;
+import org.junit.jupiter.api.parallel.ExecutionMode;
+
+@Execution(ExecutionMode.SAME_THREAD)
+class TooEarlyStatusRetryAsyncExecIT {
+
+ private static HttpServer server;
+ private static int port;
+
+ @BeforeAll
+ static void startServer() throws Exception {
+ server = HttpServer.create(new InetSocketAddress(0), 0);
+ server.setExecutor(Executors.newCachedThreadPool());
+ port = server.getAddress().getPort();
+
+ final AtomicInteger c425 = new AtomicInteger();
+ server.createContext("/once-425-then-200", ex -> {
+ try {
+ drain(ex);
+ if (c425.getAndIncrement() == 0) {
+ ex.getResponseHeaders().add("Retry-After", "0");
+ final byte[] one = new byte[]{'x'};
+ ex.sendResponseHeaders(425, one.length);
+ try (OutputStream os = ex.getResponseBody()) {
+ os.write(one);
+ }
+ } else {
+ final byte[] ok = "OK".getBytes(StandardCharsets.UTF_8);
+ ex.sendResponseHeaders(200, ok.length);
+ try (OutputStream os = ex.getResponseBody()) {
+ os.write(ok);
+ }
+ }
+ } finally {
+ ex.close();
+ }
+ });
+
+ server.createContext("/post-425-no-retry", ex -> {
+ try {
+ drain(ex);
+ ex.getResponseHeaders().add("Retry-After", "0");
+ final byte[] one = new byte[]{'x'};
+ ex.sendResponseHeaders(425, one.length);
+ try (OutputStream os = ex.getResponseBody()) {
+ os.write(one);
+ }
+ } finally {
+ ex.close();
+ }
+ });
+
+ final AtomicInteger c429 = new AtomicInteger();
+ server.createContext("/once-429-then-200", ex -> {
+ try {
+ drain(ex);
+ if (c429.getAndIncrement() == 0) {
+ ex.getResponseHeaders().add("Retry-After", "0");
+ final byte[] one = new byte[]{'x'};
+ ex.sendResponseHeaders(429, one.length);
+ try (OutputStream os = ex.getResponseBody()) {
+ os.write(one);
+ }
+ } else {
+ final byte[] ok = "OK".getBytes(StandardCharsets.UTF_8);
+ ex.sendResponseHeaders(200, ok.length);
+ try (OutputStream os = ex.getResponseBody()) {
+ os.write(ok);
+ }
+ }
+ } finally {
+ ex.close();
+ }
+ });
+
+ server.start();
+ }
+
+ @AfterAll
+ static void stopServer() {
+ if (server != null) {
+ server.stop(0);
+ }
+ }
+
+ private static void drain(final HttpExchange ex) throws IOException {
+ final byte[] buf = new byte[1024];
+ try (final java.io.InputStream in = ex.getRequestBody()) {
+ while (in.read(buf) != -1) { /* drain */ }
+ }
+ }
+
+ private CloseableHttpAsyncClient newClient() {
+ return HttpAsyncClientBuilder.create()
+ .setRetryStrategy(new TooEarlyRetryStrategy(true))
+ .addExecInterceptorLast("too-early-status-retry",
+ new TooEarlyStatusRetryAsyncExec(true))
+ .build();
+ }
+
+ @Test
+ void asyncGetIsRetriedOnceOn425() throws Exception {
+ final CloseableHttpAsyncClient client = newClient();
+ client.start();
+ try {
+ final SimpleHttpRequest req = SimpleRequestBuilder.get(
+ "http://localhost:" + port + "/once-425-then-200").build();
+
+ final CompletableFuture fut = new CompletableFuture<>();
+ client.execute(req, null, new FutureCallback() {
+ @Override
+ public void completed(final SimpleHttpResponse result) {
+ fut.complete(result);
+ }
+
+ @Override
+ public void failed(final Exception ex) {
+ fut.completeExceptionally(ex);
+ }
+
+ @Override
+ public void cancelled() {
+ fut.cancel(true);
+ }
+ });
+
+ final SimpleHttpResponse resp = fut.get();
+ assertEquals(200, resp.getCode());
+ } finally {
+ client.close();
+ }
+ }
+
+ @Test
+ void asyncPostIsNotRetriedOn425() throws Exception {
+ final CloseableHttpAsyncClient client = newClient();
+ client.start();
+ try {
+ final SimpleHttpRequest req = SimpleRequestBuilder.post(
+ "http://localhost:" + port + "/post-425-no-retry").build();
+ req.setBody("x", null);
+
+ final CompletableFuture fut = new CompletableFuture<>();
+ client.execute(req, null, new FutureCallback() {
+ @Override
+ public void completed(final SimpleHttpResponse result) {
+ fut.complete(result);
+ }
+
+ @Override
+ public void failed(final Exception ex) {
+ fut.completeExceptionally(ex);
+ }
+
+ @Override
+ public void cancelled() {
+ fut.cancel(true);
+ }
+ });
+
+ final SimpleHttpResponse resp = fut.get();
+ assertEquals(425, resp.getCode());
+ } finally {
+ client.close();
+ }
+ }
+
+ @Test
+ void asyncGetIsRetriedOnceOn429WhenEnabled() throws Exception {
+ final CloseableHttpAsyncClient client = newClient();
+ client.start();
+ try {
+ final SimpleHttpRequest req = SimpleRequestBuilder.get(
+ "http://localhost:" + port + "/once-429-then-200").build();
+
+ final CompletableFuture fut = new CompletableFuture<>();
+ client.execute(req, null, new FutureCallback() {
+ @Override
+ public void completed(final SimpleHttpResponse result) {
+ fut.complete(result);
+ }
+
+ @Override
+ public void failed(final Exception ex) {
+ fut.completeExceptionally(ex);
+ }
+
+ @Override
+ public void cancelled() {
+ fut.cancel(true);
+ }
+ });
+
+ final SimpleHttpResponse resp = fut.get();
+ assertEquals(200, resp.getCode());
+ } finally {
+ client.close();
+ }
+ }
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TooEarlyStatusRetryAsyncExecTest.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TooEarlyStatusRetryAsyncExecTest.java
new file mode 100644
index 0000000000..d6cb8f4227
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TooEarlyStatusRetryAsyncExecTest.java
@@ -0,0 +1,213 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl.async;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.sun.net.httpserver.HttpServer;
+
+import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
+import org.apache.hc.client5.http.async.methods.SimpleHttpResponse;
+import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
+import org.apache.hc.core5.concurrent.FutureCallback;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+class TooEarlyStatusRetryAsyncExecTest {
+
+ private static HttpServer server;
+ private static int port;
+
+ @BeforeAll
+ static void startServer() throws Exception {
+ server = HttpServer.create(new InetSocketAddress(0), 0);
+ port = server.getAddress().getPort();
+
+ // GET: 425 once, then 200
+ final AtomicInteger count425 = new AtomicInteger();
+ server.createContext("/once-425-then-200", ex -> {
+ try {
+ final int n = count425.incrementAndGet();
+ if (n == 1) {
+ ex.getResponseHeaders().add("Retry-After", "0");
+ ex.sendResponseHeaders(425, -1);
+ } else {
+ final byte[] body = "OK".getBytes(StandardCharsets.UTF_8);
+ ex.sendResponseHeaders(200, body.length);
+ try (OutputStream os = ex.getResponseBody()) {
+ os.write(body);
+ }
+ }
+ } catch (final Exception ignore) {
+ } finally {
+ ex.close();
+ }
+ });
+
+ // POST: always 425 -> must NOT retry
+ server.createContext("/post-425-no-retry", ex -> {
+ try {
+ ex.getResponseHeaders().add("Retry-After", "0");
+ ex.sendResponseHeaders(425, -1);
+ } catch (final Exception ignore) {
+ } finally {
+ ex.close();
+ }
+ });
+
+ // GET: 429 once, then 200
+ final AtomicInteger count429 = new AtomicInteger();
+ server.createContext("/once-429-then-200", ex -> {
+ try {
+ final int n = count429.incrementAndGet();
+ if (n == 1) {
+ ex.getResponseHeaders().add("Retry-After", "0");
+ ex.sendResponseHeaders(429, -1);
+ } else {
+ final byte[] body = "OK".getBytes(StandardCharsets.UTF_8);
+ ex.sendResponseHeaders(200, body.length);
+ try (OutputStream os = ex.getResponseBody()) {
+ os.write(body);
+ }
+ }
+ } catch (final Exception ignore) {
+ } finally {
+ ex.close();
+ }
+ });
+
+ server.start();
+ }
+
+ @AfterAll
+ static void stopServer() {
+ if (server != null) {
+ server.stop(0);
+ }
+ }
+
+ @Test
+ void asyncGetIsRetriedOn425() throws Exception {
+ final CloseableHttpAsyncClient client =
+ HttpAsyncClients.customTooEarlyAware(true).build(); // ensure this method registers the async exec interceptor
+ client.start();
+
+ final SimpleHttpRequest req = SimpleRequestBuilder.get("http://localhost:" + port + "/once-425-then-200").build();
+ final CompletableFuture fut = new CompletableFuture<>();
+
+ client.execute(req, null, new FutureCallback() {
+ @Override
+ public void completed(final SimpleHttpResponse result) {
+ fut.complete(result);
+ }
+
+ @Override
+ public void failed(final Exception ex) {
+ fut.completeExceptionally(ex);
+ }
+
+ @Override
+ public void cancelled() {
+ fut.cancel(true);
+ }
+ });
+
+ final SimpleHttpResponse resp = fut.get();
+ assertEquals(200, resp.getCode());
+ client.close();
+ }
+
+ @Test
+ void asyncPostIsNotRetriedOn425() throws Exception {
+ final CloseableHttpAsyncClient client =
+ HttpAsyncClients.customTooEarlyAware(true).build();
+ client.start();
+
+ final SimpleHttpRequest req = SimpleRequestBuilder.post("http://localhost:" + port + "/post-425-no-retry").build();
+ req.setBody("x", null);
+
+ final CompletableFuture fut = new CompletableFuture<>();
+ client.execute(req, null, new FutureCallback() {
+ @Override
+ public void completed(final SimpleHttpResponse result) {
+ fut.complete(result);
+ }
+
+ @Override
+ public void failed(final Exception ex) {
+ fut.completeExceptionally(ex);
+ }
+
+ @Override
+ public void cancelled() {
+ fut.cancel(true);
+ }
+ });
+
+ final SimpleHttpResponse resp = fut.get();
+ assertEquals(425, resp.getCode());
+ client.close();
+ }
+
+ @Test
+ void asyncGetIsRetriedOn429WhenEnabled() throws Exception {
+ final CloseableHttpAsyncClient client =
+ HttpAsyncClients.customTooEarlyAware(true).build();
+ client.start();
+
+ final SimpleHttpRequest req = SimpleRequestBuilder.get("http://localhost:" + port + "/once-429-then-200").build();
+ final CompletableFuture fut = new CompletableFuture<>();
+
+ client.execute(req, null, new FutureCallback() {
+ @Override
+ public void completed(final SimpleHttpResponse result) {
+ fut.complete(result);
+ }
+
+ @Override
+ public void failed(final Exception ex) {
+ fut.completeExceptionally(ex);
+ }
+
+ @Override
+ public void cancelled() {
+ fut.cancel(true);
+ }
+ });
+
+ final SimpleHttpResponse resp = fut.get();
+ assertEquals(200, resp.getCode());
+ client.close();
+ }
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/classic/TooEarlyStatusRetryExecIT.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/classic/TooEarlyStatusRetryExecIT.java
new file mode 100644
index 0000000000..7658b190c8
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/classic/TooEarlyStatusRetryExecIT.java
@@ -0,0 +1,210 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl.classic;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+
+import org.apache.hc.client5.http.classic.methods.HttpGet;
+import org.apache.hc.client5.http.classic.methods.HttpPost;
+import org.apache.hc.client5.http.classic.methods.HttpPut;
+import org.apache.hc.client5.http.impl.TooEarlyRetryStrategy;
+import org.apache.hc.client5.http.impl.TooEarlyStatusRetryExec;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.io.entity.EntityUtils;
+import org.apache.hc.core5.http.io.entity.InputStreamEntity;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.Execution;
+import org.junit.jupiter.api.parallel.ExecutionMode;
+
+@Execution(ExecutionMode.SAME_THREAD)
+class TooEarlyStatusRetryExecIT {
+
+ private static HttpServer server;
+ private static int port;
+
+ @BeforeAll
+ static void startServer() throws Exception {
+ server = HttpServer.create(new InetSocketAddress(0), 0);
+ server.setExecutor(Executors.newCachedThreadPool());
+ port = server.getAddress().getPort();
+
+ final AtomicInteger c425 = new AtomicInteger();
+ server.createContext("/once-425-then-200", ex -> {
+ try {
+ drain(ex);
+ if (c425.getAndIncrement() == 0) {
+ ex.getResponseHeaders().add("Retry-After", "0");
+ final byte[] one = new byte[]{'x'};
+ ex.sendResponseHeaders(425, one.length);
+ try (OutputStream os = ex.getResponseBody()) {
+ os.write(one);
+ }
+ } else {
+ final byte[] ok = "OK".getBytes(StandardCharsets.UTF_8);
+ ex.sendResponseHeaders(200, ok.length);
+ try (OutputStream os = ex.getResponseBody()) {
+ os.write(ok);
+ }
+ }
+ } finally {
+ ex.close();
+ }
+ });
+
+ server.createContext("/post-425-no-retry", ex -> {
+ try {
+ drain(ex);
+ ex.getResponseHeaders().add("Retry-After", "0");
+ final byte[] one = new byte[]{'x'};
+ ex.sendResponseHeaders(425, one.length);
+ try (OutputStream os = ex.getResponseBody()) {
+ os.write(one);
+ }
+ } finally {
+ ex.close();
+ }
+ });
+
+ final AtomicInteger c425nr = new AtomicInteger();
+ server.createContext("/put-425-nonrepeatable", ex -> {
+ try {
+ drain(ex);
+ if (c425nr.getAndIncrement() == 0) {
+ ex.getResponseHeaders().add("Retry-After", "0");
+ final byte[] one = new byte[]{'x'};
+ ex.sendResponseHeaders(425, one.length);
+ try (OutputStream os = ex.getResponseBody()) {
+ os.write(one);
+ }
+ } else {
+ final byte[] ok = "OK".getBytes(StandardCharsets.UTF_8);
+ ex.sendResponseHeaders(200, ok.length);
+ try (OutputStream os = ex.getResponseBody()) {
+ os.write(ok);
+ }
+ }
+ } finally {
+ ex.close();
+ }
+ });
+
+ server.start();
+ }
+
+ @AfterAll
+ static void stopServer() {
+ if (server != null) {
+ server.stop(0);
+ }
+ }
+
+ private static void drain(final HttpExchange ex) throws IOException {
+ final byte[] buf = new byte[1024];
+ try (final java.io.InputStream in = ex.getRequestBody()) {
+ while (in.read(buf) != -1) { /* drain */ }
+ }
+ }
+
+ private HttpHost target() {
+ return new HttpHost("http", "localhost", port);
+ }
+
+ private static String path(final String url) {
+ final URI uri = URI.create(url);
+ return uri.getRawPath() + (uri.getRawQuery() != null ? "?" + uri.getRawQuery() : "");
+ }
+
+ private CloseableHttpClient newClient() {
+ return HttpClientBuilder.create()
+ .setRetryStrategy(new TooEarlyRetryStrategy(true))
+ .addExecInterceptorLast("too-early-status-retry",
+ new TooEarlyStatusRetryExec(true))
+ .build();
+ }
+
+ @Test
+ void classicGetIsRetriedOnceOn425() throws Exception {
+ try (CloseableHttpClient client = newClient()) {
+ final HttpHost tgt = target();
+ final HttpGet get = new HttpGet(path("http://localhost:" + port + "/once-425-then-200"));
+ try (ClassicHttpResponse resp = client.executeOpen(tgt, get, null)) {
+ assertEquals(200, resp.getCode());
+ if (resp.getEntity() != null) {
+ EntityUtils.consume(resp.getEntity()); // <-- consume body before close
+ }
+ }
+ }
+ }
+
+ @Test
+ void classicPostIsNotRetriedOn425() throws Exception {
+ try (CloseableHttpClient client = newClient()) {
+ final HttpHost tgt = target();
+ final HttpPost post = new HttpPost(path("http://localhost:" + port + "/post-425-no-retry"));
+ try (ClassicHttpResponse resp = client.executeOpen(tgt, post, null)) {
+ assertEquals(425, resp.getCode());
+ if (resp.getEntity() != null) {
+ EntityUtils.consume(resp.getEntity()); // 1-byte body
+ }
+ }
+ }
+ }
+
+ @Test
+ void classicPutWithNonRepeatableEntityIsNotRetried() throws Exception {
+ try (CloseableHttpClient client = newClient()) {
+ final HttpHost tgt = target();
+ final HttpPut put = new HttpPut(path("http://localhost:" + port + "/put-425-nonrepeatable"));
+ put.setEntity(new InputStreamEntity(
+ new ByteArrayInputStream("x".getBytes(StandardCharsets.UTF_8)),
+ ContentType.TEXT_PLAIN)); // non-repeatable
+
+ try (ClassicHttpResponse resp = client.executeOpen(tgt, put, null)) {
+ assertEquals(425, resp.getCode());
+ if (resp.getEntity() != null) {
+ EntityUtils.consume(resp.getEntity()); // 1-byte body
+ }
+ }
+ }
+ }
+}