Skip to content

Commit 4f83f07

Browse files
committed
RFC 8470 early-data retry (425) + Retry-After on 429/503
Add TooEarlyRetryStrategy and async/classic exec interceptors Classic: drain & close then reacquire endpoint before retry; stable ITs Helpers: HttpClients/HttpAsyncClients *TooEarlyAware builder presets
1 parent f4027e7 commit 4f83f07

File tree

9 files changed

+1436
-0
lines changed

9 files changed

+1436
-0
lines changed
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
/*
2+
* ====================================================================
3+
* Licensed to the Apache Software Foundation (ASF) under one
4+
* or more contributor license agreements. See the NOTICE file
5+
* distributed with this work for additional information
6+
* regarding copyright ownership. The ASF licenses this file
7+
* to you under the Apache License, Version 2.0 (the
8+
* "License"); you may not use this file except in compliance
9+
* with the License. You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing,
14+
* software distributed under the License is distributed on an
15+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16+
* KIND, either express or implied. See the License for the
17+
* specific language governing permissions and limitations
18+
* under the License.
19+
* ====================================================================
20+
*
21+
* This software consists of voluntary contributions made by many
22+
* individuals on behalf of the Apache Software Foundation. For more
23+
* information on the Apache Software Foundation, please see
24+
* <http://www.apache.org/>.
25+
*
26+
*/
27+
package org.apache.hc.client5.http.impl;
28+
29+
import java.io.IOException;
30+
import java.time.Instant;
31+
import java.time.ZonedDateTime;
32+
import java.time.format.DateTimeFormatter;
33+
import java.time.format.DateTimeParseException;
34+
import java.util.Arrays;
35+
import java.util.Collections;
36+
import java.util.HashSet;
37+
import java.util.Set;
38+
39+
import org.apache.hc.client5.http.HttpRequestRetryStrategy;
40+
import org.apache.hc.core5.http.Header;
41+
import org.apache.hc.core5.http.HttpEntity;
42+
import org.apache.hc.core5.http.HttpEntityContainer;
43+
import org.apache.hc.core5.http.HttpRequest;
44+
import org.apache.hc.core5.http.HttpResponse;
45+
import org.apache.hc.core5.http.HttpStatus;
46+
import org.apache.hc.core5.http.Method;
47+
import org.apache.hc.core5.http.protocol.HttpContext;
48+
import org.apache.hc.core5.http.protocol.HttpCoreContext;
49+
import org.apache.hc.core5.util.Args;
50+
import org.apache.hc.core5.util.TimeValue;
51+
52+
/**
53+
* Retry policy for RFC 8470 (<em>Using Early Data in HTTP</em>).
54+
* <p>
55+
* This strategy allows a single automatic retry on {@code 425 Too Early}
56+
* (and, optionally, on {@code 429} and {@code 503} honoring {@code Retry-After})
57+
* for requests that are considered replay-safe:
58+
* idempotent methods and, when present, repeatable entities.
59+
* </p>
60+
*
61+
* <p><strong>Notes</strong></p>
62+
* <ul>
63+
* <li>On {@code 425}, the context attribute
64+
* {@link #DISABLE_EARLY_DATA_ATTR} is set to {@code Boolean.TRUE}
65+
* so a TLS layer with 0-RTT support can avoid early data on the retry.</li>
66+
* <li>This class is thread-safe and can be reused across clients.</li>
67+
* </ul>
68+
*
69+
* @since 5.6
70+
*/
71+
public final class TooEarlyRetryStrategy implements HttpRequestRetryStrategy {
72+
73+
/**
74+
* Context attribute key used to signal the transport/TLS layer that the next attempt
75+
* must not use TLS 0-RTT early data. Implementations that support early data may
76+
* check this flag and force a full handshake on retry.
77+
*/
78+
public static final String DISABLE_EARLY_DATA_ATTR = "http.client.tls.early_data.disable";
79+
80+
private final int maxRetries;
81+
private final boolean include429and503;
82+
private final HttpRequestRetryStrategy delegateForExceptions; // optional, may be null
83+
84+
/**
85+
* Creates a strategy that retries once on {@code 425 Too Early}.
86+
* <p>
87+
* When {@code include429and503} is {@code true}, the same rules are also
88+
* applied to {@code 429 Too Many Requests} and {@code 503 Service Unavailable},
89+
* honoring {@code Retry-After} when present.
90+
* </p>
91+
*
92+
* @param include429and503 whether to also retry 429/503
93+
* @since 5.6
94+
*/
95+
public TooEarlyRetryStrategy(final boolean include429and503) {
96+
this(1, include429and503, null);
97+
}
98+
99+
/**
100+
* Creates a strategy with custom limits and optional delegation for I/O exception retries.
101+
*
102+
* @param maxRetries maximum retry attempts for eligible status codes (recommended: {@code 1})
103+
* @param include429and503 whether to also retry 429/503
104+
* @param delegateForExceptions optional delegate to handle I/O exception retries; may be {@code null}
105+
* @since 5.6
106+
*/
107+
public TooEarlyRetryStrategy(
108+
final int maxRetries,
109+
final boolean include429and503,
110+
final HttpRequestRetryStrategy delegateForExceptions) {
111+
this.maxRetries = Args.positive(maxRetries, "maxRetries");
112+
this.include429and503 = include429and503;
113+
this.delegateForExceptions = delegateForExceptions;
114+
}
115+
116+
/**
117+
* Delegates I/O exception retry decisions to {@code delegateForExceptions} if provided;
118+
* otherwise returns {@code false}.
119+
*
120+
* @param request the original request
121+
* @param exception I/O exception that occurred
122+
* @param execCount execution count (including the initial attempt)
123+
* @param context HTTP context
124+
* @return {@code true} to retry, {@code false} otherwise
125+
* @since 5.6
126+
*/
127+
@Override
128+
public boolean retryRequest(
129+
final HttpRequest request,
130+
final IOException exception,
131+
final int execCount,
132+
final HttpContext context) {
133+
return delegateForExceptions != null
134+
&& delegateForExceptions.retryRequest(request, exception, execCount, context);
135+
}
136+
137+
/**
138+
* Decides status-based retries for {@code 425} (and optionally {@code 429/503}).
139+
* <p>
140+
* Retries only when:
141+
* </p>
142+
* <ul>
143+
* <li>{@code execCount} ≤ {@code maxRetries},</li>
144+
* <li>the original method is idempotent, and</li>
145+
* <li>any request entity is {@linkplain HttpEntity#isRepeatable() repeatable}.</li>
146+
* </ul>
147+
* <p>
148+
* On {@code 425}, sets {@link #DISABLE_EARLY_DATA_ATTR} to {@code Boolean.TRUE}
149+
* in the provided {@link HttpContext}.
150+
* </p>
151+
*
152+
* @param response the response received
153+
* @param execCount execution count (including the initial attempt)
154+
* @param context HTTP context (used to obtain the original request)
155+
* @return {@code true} if the request should be retried, {@code false} otherwise
156+
* @since 5.6
157+
*/
158+
@Override
159+
public boolean retryRequest(
160+
final HttpResponse response,
161+
final int execCount,
162+
final HttpContext context) {
163+
164+
final int code = response.getCode();
165+
final boolean eligible =
166+
code == HttpStatus.SC_TOO_EARLY || include429and503 && (code == HttpStatus.SC_TOO_MANY_REQUESTS
167+
|| code == HttpStatus.SC_SERVICE_UNAVAILABLE);
168+
169+
if (!eligible || execCount > maxRetries) {
170+
return false;
171+
}
172+
173+
final HttpRequest original = HttpCoreContext.cast(context).getRequest();
174+
if (original == null) {
175+
return false;
176+
}
177+
178+
if (!Method.normalizedValueOf(original.getMethod()).isIdempotent()) {
179+
return false;
180+
}
181+
182+
// Require repeatable entity when present (classic requests expose it via HttpEntityContainer).
183+
if (original instanceof HttpEntityContainer) {
184+
final HttpEntity entity = ((HttpEntityContainer) original).getEntity();
185+
if (entity != null && !entity.isRepeatable()) {
186+
return false;
187+
}
188+
}
189+
190+
if (code == HttpStatus.SC_TOO_EARLY) {
191+
context.setAttribute(DISABLE_EARLY_DATA_ATTR, Boolean.TRUE);
192+
}
193+
194+
return true;
195+
}
196+
197+
/**
198+
* Computes the back-off interval from {@code Retry-After}, when present, for
199+
* eligible status codes.
200+
* <p>
201+
* Supports both delta-seconds and HTTP-date (RFC&nbsp;1123) formats.
202+
* Unparseable values and past dates yield {@link TimeValue#ZERO_MILLISECONDS}.
203+
* </p>
204+
*
205+
* @param response the response
206+
* @param execCount execution count (including the initial attempt)
207+
* @param context HTTP context (unused)
208+
* @return a {@link TimeValue} to wait before retrying; {@code ZERO_MILLISECONDS} if none
209+
* @since 5.6
210+
*/
211+
@Override
212+
public TimeValue getRetryInterval(
213+
final HttpResponse response,
214+
final int execCount,
215+
final HttpContext context) {
216+
217+
final int code = response.getCode();
218+
final boolean eligible =
219+
code == HttpStatus.SC_TOO_EARLY || include429and503 && (code == HttpStatus.SC_TOO_MANY_REQUESTS
220+
|| code == HttpStatus.SC_SERVICE_UNAVAILABLE);
221+
222+
if (!eligible) {
223+
return TimeValue.ZERO_MILLISECONDS;
224+
}
225+
226+
final Header h = response.getFirstHeader("Retry-After");
227+
if (h == null) {
228+
return TimeValue.ZERO_MILLISECONDS;
229+
}
230+
231+
final String v = h.getValue().trim();
232+
233+
// 1) delta-seconds
234+
try {
235+
final long seconds = Long.parseLong(v);
236+
if (seconds >= 0L) {
237+
return TimeValue.ofSeconds(seconds);
238+
}
239+
} catch (final NumberFormatException ignore) {
240+
// fall through to HTTP-date
241+
}
242+
243+
// 2) HTTP-date (RFC 1123)
244+
try {
245+
final ZonedDateTime when = ZonedDateTime.parse(v, DateTimeFormatter.RFC_1123_DATE_TIME);
246+
final long millis = when.toInstant().toEpochMilli() - Instant.now().toEpochMilli();
247+
return millis > 0L ? TimeValue.ofMilliseconds(millis) : TimeValue.ZERO_MILLISECONDS;
248+
} catch (final DateTimeParseException ignore) {
249+
return TimeValue.ZERO_MILLISECONDS;
250+
}
251+
}
252+
253+
@Override
254+
public String toString() {
255+
return "TooEarlyRetryStrategy(maxRetries=" + maxRetries +
256+
", include429and503=" + include429and503 + ')';
257+
}
258+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/*
2+
* ====================================================================
3+
* Licensed to the Apache Software Foundation (ASF) under one
4+
* or more contributor license agreements. See the NOTICE file
5+
* distributed with this work for additional information
6+
* regarding copyright ownership. The ASF licenses this file
7+
* to you under the Apache License, Version 2.0 (the
8+
* "License"); you may not use this file except in compliance
9+
* with the License. You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing,
14+
* software distributed under the License is distributed on an
15+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16+
* KIND, either express or implied. See the License for the
17+
* specific language governing permissions and limitations
18+
* under the License.
19+
* ====================================================================
20+
*
21+
* This software consists of voluntary contributions made by many
22+
* individuals on behalf of the Apache Software Foundation. For more
23+
* information on the Apache Software Foundation, please see
24+
* <http://www.apache.org/>.
25+
*
26+
*/
27+
package org.apache.hc.client5.http.impl;
28+
29+
import java.io.IOException;
30+
import java.util.Arrays;
31+
import java.util.Collections;
32+
import java.util.HashSet;
33+
import java.util.Set;
34+
35+
import org.apache.hc.client5.http.classic.ExecChain;
36+
import org.apache.hc.client5.http.classic.ExecChainHandler;
37+
import org.apache.hc.core5.http.ClassicHttpRequest;
38+
import org.apache.hc.core5.http.ClassicHttpResponse;
39+
import org.apache.hc.core5.http.HttpEntity;
40+
import org.apache.hc.core5.http.HttpException;
41+
import org.apache.hc.core5.http.HttpStatus;
42+
import org.apache.hc.core5.http.Method;
43+
import org.apache.hc.core5.http.io.entity.EntityUtils;
44+
45+
/**
46+
* Classic exec-chain interceptor that re-executes the request exactly once on
47+
* {@code 425 Too Early} (and optionally on {@code 429}/{@code 503})
48+
* for idempotent requests with repeatable entities.
49+
*
50+
* @since 5.6
51+
*/
52+
public final class TooEarlyStatusRetryExec implements ExecChainHandler {
53+
54+
private static final String RETRIED_ATTR = "http.client.too_early.retried";
55+
56+
private final boolean include429and503;
57+
58+
public TooEarlyStatusRetryExec(final boolean include429and503) {
59+
this.include429and503 = include429and503;
60+
}
61+
62+
@Override
63+
public ClassicHttpResponse execute(
64+
final ClassicHttpRequest request,
65+
final ExecChain.Scope scope,
66+
final ExecChain chain) throws IOException, HttpException {
67+
68+
final ClassicHttpResponse response = chain.proceed(request, scope);
69+
70+
final int code = response.getCode();
71+
final boolean eligible = code == HttpStatus.SC_TOO_EARLY || include429and503 && (code == HttpStatus.SC_TOO_MANY_REQUESTS
72+
|| code == HttpStatus.SC_SERVICE_UNAVAILABLE);
73+
74+
final boolean alreadyRetried = Boolean.TRUE.equals(scope.clientContext.getAttribute(RETRIED_ATTR));
75+
final boolean idempotent = Method.normalizedValueOf(request.getMethod()).isIdempotent();
76+
final HttpEntity reqEntity = request.getEntity();
77+
final boolean repeatable = reqEntity == null || reqEntity.isRepeatable();
78+
79+
if (eligible && !alreadyRetried && idempotent && repeatable) {
80+
// RFC 8470: tell TLS/transport to avoid early data on the retry
81+
if (code == HttpStatus.SC_TOO_EARLY) {
82+
scope.clientContext.setAttribute(
83+
TooEarlyRetryStrategy.DISABLE_EARLY_DATA_ATTR, Boolean.TRUE);
84+
}
85+
scope.clientContext.setAttribute(RETRIED_ATTR, Boolean.TRUE);
86+
87+
// Drain & close first response (ignore errors – we discard it anyway)
88+
try {
89+
final HttpEntity respEntity = response.getEntity();
90+
if (respEntity != null) {
91+
EntityUtils.consume(respEntity);
92+
}
93+
} catch (final Exception ignore) {
94+
}
95+
try {
96+
response.close();
97+
} catch (final Exception ignore) {
98+
}
99+
100+
// The first exchange may have released the endpoint; reacquire before retrying.
101+
try {
102+
scope.execRuntime.discardEndpoint(); // safe even if none is held
103+
} catch (final Exception ignore) {
104+
}
105+
// 5.6 signature: (String id, HttpRoute route, Object state, HttpClientContext ctx)
106+
scope.execRuntime.acquireEndpoint(null, scope.route, null, scope.clientContext);
107+
108+
// Retry once
109+
return chain.proceed(request, scope);
110+
}
111+
112+
return response;
113+
}
114+
}

0 commit comments

Comments
 (0)