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

+ * + * + * @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: + *

+ * + *

+ * 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: + * + */ + 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 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 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 + } + } + } + } +}