Skip to content

Commit f4027e7

Browse files
RFC 8297 Early Hints (103) support. (#707)
Expose EarlyHintsListener + builder hooks ( only for async) Deliver 103 via EarlyHintsAsyncExec; final response unchanged
1 parent 092cae8 commit f4027e7

File tree

5 files changed

+573
-0
lines changed

5 files changed

+573
-0
lines changed
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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;
28+
29+
import java.io.IOException;
30+
31+
import org.apache.hc.core5.http.HttpException;
32+
import org.apache.hc.core5.http.HttpResponse;
33+
import org.apache.hc.core5.http.protocol.HttpContext;
34+
35+
/**
36+
* Callback interface for receiving {@code 103 Early Hints}
37+
* informational responses emitted by the server before the final response.
38+
*
39+
* <p>The listener may be invoked multiple times per request, once for each
40+
* {@code 103} received. It is never invoked for the final (non-1xx) response.</p>
41+
*
42+
* <p>Implementations should be fast and non-blocking. If heavy work is needed,
43+
* offload it to an application executor.</p>
44+
*
45+
* @since 5.6
46+
*/
47+
@FunctionalInterface
48+
public interface EarlyHintsListener {
49+
50+
/**
51+
* Called for each received {@code 103 Early Hints} informational response.
52+
*
53+
* @param hints the {@code 103} response object as received on the wire
54+
* @param context the current execution context (never {@code null})
55+
* @throws HttpException to signal an HTTP-layer error while handling hints
56+
* @throws IOException to signal an I/O error while handling hints
57+
*/
58+
void onEarlyHints(HttpResponse hints, HttpContext context) throws HttpException, IOException;
59+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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.async;
28+
29+
import java.io.IOException;
30+
31+
import org.apache.hc.client5.http.EarlyHintsListener;
32+
import org.apache.hc.client5.http.async.AsyncExecCallback;
33+
import org.apache.hc.client5.http.async.AsyncExecChain;
34+
import org.apache.hc.client5.http.async.AsyncExecChainHandler;
35+
import org.apache.hc.core5.http.EntityDetails;
36+
import org.apache.hc.core5.http.HttpException;
37+
import org.apache.hc.core5.http.HttpRequest;
38+
import org.apache.hc.core5.http.HttpResponse;
39+
import org.apache.hc.core5.http.HttpStatus;
40+
import org.apache.hc.core5.http.nio.AsyncDataConsumer;
41+
import org.apache.hc.core5.http.nio.AsyncEntityProducer;
42+
43+
/**
44+
* Execution chain handler that delivers {@code 103 Early Hints}
45+
* informational responses to a user-provided
46+
* {@link org.apache.hc.client5.http.EarlyHintsListener}
47+
* without affecting processing of the final (non-1xx) response.
48+
*
49+
* <p>This handler forwards each {@code 103} informational response to the
50+
* listener. All other responses (including the final response) are delegated
51+
* unchanged.</p>
52+
*
53+
* <p>For security and interoperability, applications typically act only on
54+
* headers considered safe in Early Hints (for example, {@code Link} with
55+
* {@code rel=preload} or {@code rel=preconnect}).</p>
56+
*
57+
* @see org.apache.hc.client5.http.EarlyHintsListener
58+
* @see org.apache.hc.core5.http.HttpStatus#SC_EARLY_HINTS
59+
* @see org.apache.hc.core5.http.nio.ResponseChannel#sendInformation(org.apache.hc.core5.http.HttpResponse, org.apache.hc.core5.http.protocol.HttpContext)
60+
* @since 5.6
61+
*/
62+
63+
public final class EarlyHintsAsyncExec implements AsyncExecChainHandler {
64+
private final EarlyHintsListener listener;
65+
66+
public EarlyHintsAsyncExec(final EarlyHintsListener listener) {
67+
this.listener = listener;
68+
}
69+
70+
@Override
71+
public void execute(final HttpRequest request,
72+
final AsyncEntityProducer entityProducer,
73+
final AsyncExecChain.Scope scope,
74+
final AsyncExecChain chain,
75+
final AsyncExecCallback callback) throws HttpException, IOException {
76+
77+
if (listener == null) {
78+
chain.proceed(request, entityProducer, scope, callback);
79+
return;
80+
}
81+
82+
chain.proceed(request, entityProducer, scope, new AsyncExecCallback() {
83+
@Override
84+
public void handleInformationResponse(final HttpResponse response)
85+
throws HttpException, java.io.IOException {
86+
if (response.getCode() == HttpStatus.SC_EARLY_HINTS) {
87+
listener.onEarlyHints(response, scope.clientContext);
88+
}
89+
callback.handleInformationResponse(response);
90+
}
91+
92+
@Override
93+
public AsyncDataConsumer handleResponse(
94+
final HttpResponse response, final EntityDetails entityDetails)
95+
throws HttpException, java.io.IOException {
96+
return callback.handleResponse(response, entityDetails);
97+
}
98+
99+
@Override
100+
public void completed() {
101+
callback.completed();
102+
}
103+
104+
@Override
105+
public void failed(final Exception cause) {
106+
callback.failed(cause);
107+
}
108+
});
109+
}
110+
}

httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343

4444
import org.apache.hc.client5.http.AuthenticationStrategy;
4545
import org.apache.hc.client5.http.ConnectionKeepAliveStrategy;
46+
import org.apache.hc.client5.http.EarlyHintsListener;
4647
import org.apache.hc.client5.http.HttpRequestRetryStrategy;
4748
import org.apache.hc.client5.http.SchemePortResolver;
4849
import org.apache.hc.client5.http.UserTokenHandler;
@@ -266,6 +267,8 @@ private ExecInterceptorEntry(
266267

267268
private ProxySelector proxySelector;
268269

270+
private EarlyHintsListener earlyHintsListener;
271+
269272
/**
270273
* Maps {@code Content-Encoding} tokens to decoder factories in insertion order.
271274
*/
@@ -893,6 +896,22 @@ public HttpAsyncClientBuilder disableContentCompression() {
893896
return this;
894897
}
895898

899+
/**
900+
* Registers a global {@link org.apache.hc.client5.http.EarlyHintsListener}
901+
* that will be notified when the client receives {@code 103 Early Hints}
902+
* informational responses for any request executed by the built client.
903+
*
904+
* @param listener the listener to receive {@code 103 Early Hints} events,
905+
* or {@code null} to remove the listener
906+
* @return this builder
907+
* @since 5.6
908+
*/
909+
public final HttpAsyncClientBuilder setEarlyHintsListener(final EarlyHintsListener listener) {
910+
this.earlyHintsListener = listener;
911+
return this;
912+
}
913+
914+
896915
/**
897916
* Request exec chain customization and extension.
898917
* <p>
@@ -1035,6 +1054,11 @@ public CloseableHttpAsyncClient build() {
10351054
authCachingDisabled),
10361055
ChainElement.CONNECT.name());
10371056

1057+
if (earlyHintsListener != null) {
1058+
addExecInterceptorBefore(ChainElement.PROTOCOL.name(), "early-hints",
1059+
new EarlyHintsAsyncExec(earlyHintsListener));
1060+
}
1061+
10381062
execChainDefinition.addFirst(
10391063
new AsyncProtocolExec(
10401064
targetAuthStrategyCopy,
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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.examples;
28+
29+
import java.io.IOException;
30+
import java.net.InetSocketAddress;
31+
import java.nio.ByteBuffer;
32+
import java.nio.charset.StandardCharsets;
33+
import java.util.List;
34+
import java.util.concurrent.Future;
35+
import java.util.concurrent.TimeUnit;
36+
37+
import org.apache.hc.client5.http.EarlyHintsListener;
38+
import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
39+
import org.apache.hc.client5.http.async.methods.SimpleHttpResponse;
40+
import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
41+
import org.apache.hc.client5.http.config.TlsConfig;
42+
import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
43+
import org.apache.hc.client5.http.impl.async.HttpAsyncClients;
44+
import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder;
45+
import org.apache.hc.core5.http.ContentType;
46+
import org.apache.hc.core5.http.EntityDetails;
47+
import org.apache.hc.core5.http.Header;
48+
import org.apache.hc.core5.http.HttpException;
49+
import org.apache.hc.core5.http.HttpRequest;
50+
import org.apache.hc.core5.http.HttpStatus;
51+
import org.apache.hc.core5.http.URIScheme;
52+
import org.apache.hc.core5.http.impl.BasicEntityDetails;
53+
import org.apache.hc.core5.http.impl.bootstrap.AsyncServerBootstrap;
54+
import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncServer;
55+
import org.apache.hc.core5.http.message.BasicHttpResponse;
56+
import org.apache.hc.core5.http.nio.AsyncServerExchangeHandler;
57+
import org.apache.hc.core5.http.nio.CapacityChannel;
58+
import org.apache.hc.core5.http.nio.DataStreamChannel;
59+
import org.apache.hc.core5.http.nio.ResponseChannel;
60+
import org.apache.hc.core5.http.protocol.HttpContext;
61+
import org.apache.hc.core5.io.CloseMode;
62+
import org.apache.hc.core5.reactor.ListenerEndpoint;
63+
64+
/**
65+
* Minimal end-to-end demo for {@code 103 Early Hints} using the async client.
66+
*
67+
* <p>This example starts a tiny local async HTTP server that:
68+
* <ol>
69+
* <li>sends a {@code 103} informational response with two {@code Link} headers, then</li>
70+
* <li>completes the exchange with a final {@code 200 OK} and a short body.</li>
71+
* </ol>
72+
* The async client registers an Early Hints listener, prints any received {@code 103}
73+
* headers, and then prints the final status and body.</p>
74+
*
75+
* <p>Use this sample to see how to wire {@code setEarlyHintsListener(...)} and verify that
76+
* Early Hints do not interfere with normal response processing.</p>
77+
*/
78+
79+
public class AsyncClientEarlyHintsEndToEnd {
80+
81+
public static void main(final String[] args) throws Exception {
82+
// --- Start minimal async server that sends 103 then 200
83+
final HttpAsyncServer server = AsyncServerBootstrap.bootstrap()
84+
.setCanonicalHostName("localhost")
85+
.register("/eh", () -> new AsyncServerExchangeHandler() {
86+
87+
private final byte[] body = "OK".getBytes(StandardCharsets.US_ASCII);
88+
private volatile boolean sent;
89+
90+
@Override
91+
public void handleRequest(final HttpRequest request,
92+
final EntityDetails entityDetails,
93+
final ResponseChannel channel,
94+
final HttpContext context)
95+
throws HttpException, IOException {
96+
97+
// 103 Early Hints
98+
final BasicHttpResponse hints = new BasicHttpResponse(HttpStatus.SC_EARLY_HINTS);
99+
hints.addHeader("Link", "</style.css>; rel=preload; as=style");
100+
hints.addHeader("Link", "</script.js>; rel=preload; as=script");
101+
channel.sendInformation(hints, context);
102+
103+
// Final 200 (announce entity; body will be produced in produce())
104+
final BasicHttpResponse ok = new BasicHttpResponse(HttpStatus.SC_OK);
105+
ok.addHeader("Content-Type", ContentType.TEXT_PLAIN.toString());
106+
final BasicEntityDetails details = new BasicEntityDetails(body.length, ContentType.TEXT_PLAIN);
107+
channel.sendResponse(ok, details, context);
108+
}
109+
110+
// ---- AsyncDataConsumer (request body not expected)
111+
@Override
112+
public void updateCapacity(final CapacityChannel ch) throws IOException {
113+
ch.update(Integer.MAX_VALUE);
114+
}
115+
116+
@Override
117+
public void consume(final ByteBuffer src) { /* no-op */ }
118+
119+
@Override
120+
public void streamEnd(final List<? extends Header> trailers) { /* no-op */ }
121+
122+
// ---- AsyncDataProducer (MUST implement both of these)
123+
@Override
124+
public void produce(final DataStreamChannel ch) throws IOException {
125+
if (!sent) {
126+
ch.write(java.nio.ByteBuffer.wrap(body));
127+
ch.endStream();
128+
sent = true;
129+
}
130+
}
131+
132+
@Override
133+
public int available() {
134+
return sent ? 0 : body.length;
135+
}
136+
137+
@Override
138+
public void failed(final Exception cause) { /* no-op for demo */ }
139+
140+
@Override
141+
public void releaseResources() { /* no-op for demo */ }
142+
})
143+
.create();
144+
server.start();
145+
final Future<ListenerEndpoint> lf = server.listen(new InetSocketAddress(0), URIScheme.HTTP);
146+
final int port = ((InetSocketAddress) lf.get().getAddress()).getPort();
147+
148+
// --- Async client with Early Hints listener
149+
final EarlyHintsListener hintsListener = (hints, ctx) -> {
150+
System.out.println("[client] Early Hints 103:");
151+
for (final Header h : hints.getHeaders("Link")) {
152+
System.out.println(" " + h.getValue());
153+
}
154+
};
155+
156+
try (final CloseableHttpAsyncClient client = HttpAsyncClients.custom()
157+
.setConnectionManager(
158+
PoolingAsyncClientConnectionManagerBuilder.create()
159+
.setDefaultTlsConfig(TlsConfig.DEFAULT) // plain HTTP here; keep TLS config for real targets
160+
.build())
161+
.setEarlyHintsListener(hintsListener)
162+
.build()) {
163+
client.start();
164+
165+
final SimpleHttpRequest req = SimpleRequestBuilder.get("http://localhost:" + port + "/eh").build();
166+
final SimpleHttpResponse resp = client.execute(req, null).get(5, TimeUnit.SECONDS);
167+
168+
System.out.println("[client] final: " + resp.getCode() + " " + resp.getReasonPhrase());
169+
System.out.println("[client] body: " + resp.getBodyText());
170+
} finally {
171+
server.close(CloseMode.GRACEFUL);
172+
}
173+
}
174+
}

0 commit comments

Comments
 (0)