From 0fc9dd2930934e13577390b0cf6d01ca08d40c28 Mon Sep 17 00:00:00 2001
From: Arturo Bernal
Date: Sat, 16 Aug 2025 13:23:19 +0200
Subject: [PATCH] RFC 8297 Early Hints (103) support. Expose EarlyHintsListener
+ builder hooks ( only for async) Deliver 103 via EarlyHintsAsyncExec; final
response unchanged
---
.../hc/client5/http/EarlyHintsListener.java | 59 +++++
.../http/impl/async/EarlyHintsAsyncExec.java | 110 ++++++++++
.../impl/async/HttpAsyncClientBuilder.java | 24 ++
.../AsyncClientEarlyHintsEndToEnd.java | 174 +++++++++++++++
.../impl/async/EarlyHintsAsyncExecTest.java | 206 ++++++++++++++++++
5 files changed, 573 insertions(+)
create mode 100644 httpclient5/src/main/java/org/apache/hc/client5/http/EarlyHintsListener.java
create mode 100644 httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/EarlyHintsAsyncExec.java
create mode 100644 httpclient5/src/test/java/org/apache/hc/client5/http/examples/AsyncClientEarlyHintsEndToEnd.java
create mode 100644 httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/EarlyHintsAsyncExecTest.java
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/EarlyHintsListener.java b/httpclient5/src/main/java/org/apache/hc/client5/http/EarlyHintsListener.java
new file mode 100644
index 0000000000..5cb3c4f228
--- /dev/null
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/EarlyHintsListener.java
@@ -0,0 +1,59 @@
+/*
+ * ====================================================================
+ * 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;
+
+import java.io.IOException;
+
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.HttpResponse;
+import org.apache.hc.core5.http.protocol.HttpContext;
+
+/**
+ * Callback interface for receiving {@code 103 Early Hints}
+ * informational responses emitted by the server before the final response.
+ *
+ * The listener may be invoked multiple times per request, once for each
+ * {@code 103} received. It is never invoked for the final (non-1xx) response.
+ *
+ * Implementations should be fast and non-blocking. If heavy work is needed,
+ * offload it to an application executor.
+ *
+ * @since 5.6
+ */
+@FunctionalInterface
+public interface EarlyHintsListener {
+
+ /**
+ * Called for each received {@code 103 Early Hints} informational response.
+ *
+ * @param hints the {@code 103} response object as received on the wire
+ * @param context the current execution context (never {@code null})
+ * @throws HttpException to signal an HTTP-layer error while handling hints
+ * @throws IOException to signal an I/O error while handling hints
+ */
+ void onEarlyHints(HttpResponse hints, HttpContext context) throws HttpException, IOException;
+}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/EarlyHintsAsyncExec.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/EarlyHintsAsyncExec.java
new file mode 100644
index 0000000000..7aef12cd66
--- /dev/null
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/EarlyHintsAsyncExec.java
@@ -0,0 +1,110 @@
+/*
+ * ====================================================================
+ * 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 org.apache.hc.client5.http.EarlyHintsListener;
+import org.apache.hc.client5.http.async.AsyncExecCallback;
+import org.apache.hc.client5.http.async.AsyncExecChain;
+import org.apache.hc.client5.http.async.AsyncExecChainHandler;
+import org.apache.hc.core5.http.EntityDetails;
+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.nio.AsyncDataConsumer;
+import org.apache.hc.core5.http.nio.AsyncEntityProducer;
+
+/**
+ * Execution chain handler that delivers {@code 103 Early Hints}
+ * informational responses to a user-provided
+ * {@link org.apache.hc.client5.http.EarlyHintsListener}
+ * without affecting processing of the final (non-1xx) response.
+ *
+ * This handler forwards each {@code 103} informational response to the
+ * listener. All other responses (including the final response) are delegated
+ * unchanged.
+ *
+ * For security and interoperability, applications typically act only on
+ * headers considered safe in Early Hints (for example, {@code Link} with
+ * {@code rel=preload} or {@code rel=preconnect}).
+ *
+ * @see org.apache.hc.client5.http.EarlyHintsListener
+ * @see org.apache.hc.core5.http.HttpStatus#SC_EARLY_HINTS
+ * @see org.apache.hc.core5.http.nio.ResponseChannel#sendInformation(org.apache.hc.core5.http.HttpResponse, org.apache.hc.core5.http.protocol.HttpContext)
+ * @since 5.6
+ */
+
+public final class EarlyHintsAsyncExec implements AsyncExecChainHandler {
+ private final EarlyHintsListener listener;
+
+ public EarlyHintsAsyncExec(final EarlyHintsListener listener) {
+ this.listener = listener;
+ }
+
+ @Override
+ public void execute(final HttpRequest request,
+ final AsyncEntityProducer entityProducer,
+ final AsyncExecChain.Scope scope,
+ final AsyncExecChain chain,
+ final AsyncExecCallback callback) throws HttpException, IOException {
+
+ if (listener == null) {
+ chain.proceed(request, entityProducer, scope, callback);
+ return;
+ }
+
+ chain.proceed(request, entityProducer, scope, new AsyncExecCallback() {
+ @Override
+ public void handleInformationResponse(final HttpResponse response)
+ throws HttpException, java.io.IOException {
+ if (response.getCode() == HttpStatus.SC_EARLY_HINTS) {
+ listener.onEarlyHints(response, scope.clientContext);
+ }
+ callback.handleInformationResponse(response);
+ }
+
+ @Override
+ public AsyncDataConsumer handleResponse(
+ final HttpResponse response, final EntityDetails entityDetails)
+ throws HttpException, java.io.IOException {
+ return callback.handleResponse(response, entityDetails);
+ }
+
+ @Override
+ public void completed() {
+ callback.completed();
+ }
+
+ @Override
+ public void failed(final Exception cause) {
+ callback.failed(cause);
+ }
+ });
+ }
+}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java
index 6fc1d88c1f..26f7022209 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java
@@ -43,6 +43,7 @@
import org.apache.hc.client5.http.AuthenticationStrategy;
import org.apache.hc.client5.http.ConnectionKeepAliveStrategy;
+import org.apache.hc.client5.http.EarlyHintsListener;
import org.apache.hc.client5.http.HttpRequestRetryStrategy;
import org.apache.hc.client5.http.SchemePortResolver;
import org.apache.hc.client5.http.UserTokenHandler;
@@ -266,6 +267,8 @@ private ExecInterceptorEntry(
private ProxySelector proxySelector;
+ private EarlyHintsListener earlyHintsListener;
+
/**
* Maps {@code Content-Encoding} tokens to decoder factories in insertion order.
*/
@@ -893,6 +896,22 @@ public HttpAsyncClientBuilder disableContentCompression() {
return this;
}
+ /**
+ * Registers a global {@link org.apache.hc.client5.http.EarlyHintsListener}
+ * that will be notified when the client receives {@code 103 Early Hints}
+ * informational responses for any request executed by the built client.
+ *
+ * @param listener the listener to receive {@code 103 Early Hints} events,
+ * or {@code null} to remove the listener
+ * @return this builder
+ * @since 5.6
+ */
+ public final HttpAsyncClientBuilder setEarlyHintsListener(final EarlyHintsListener listener) {
+ this.earlyHintsListener = listener;
+ return this;
+ }
+
+
/**
* Request exec chain customization and extension.
*
@@ -1035,6 +1054,11 @@ public CloseableHttpAsyncClient build() {
authCachingDisabled),
ChainElement.CONNECT.name());
+ if (earlyHintsListener != null) {
+ addExecInterceptorBefore(ChainElement.PROTOCOL.name(), "early-hints",
+ new EarlyHintsAsyncExec(earlyHintsListener));
+ }
+
execChainDefinition.addFirst(
new AsyncProtocolExec(
targetAuthStrategyCopy,
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AsyncClientEarlyHintsEndToEnd.java b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AsyncClientEarlyHintsEndToEnd.java
new file mode 100644
index 0000000000..67b6a77da7
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AsyncClientEarlyHintsEndToEnd.java
@@ -0,0 +1,174 @@
+/*
+ * ====================================================================
+ * 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.examples;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.hc.client5.http.EarlyHintsListener;
+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.config.TlsConfig;
+import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
+import org.apache.hc.client5.http.impl.async.HttpAsyncClients;
+import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder;
+import org.apache.hc.core5.http.ContentType;
+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.HttpStatus;
+import org.apache.hc.core5.http.URIScheme;
+import org.apache.hc.core5.http.impl.BasicEntityDetails;
+import org.apache.hc.core5.http.impl.bootstrap.AsyncServerBootstrap;
+import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncServer;
+import org.apache.hc.core5.http.message.BasicHttpResponse;
+import org.apache.hc.core5.http.nio.AsyncServerExchangeHandler;
+import org.apache.hc.core5.http.nio.CapacityChannel;
+import org.apache.hc.core5.http.nio.DataStreamChannel;
+import org.apache.hc.core5.http.nio.ResponseChannel;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.reactor.ListenerEndpoint;
+
+/**
+ * Minimal end-to-end demo for {@code 103 Early Hints} using the async client.
+ *
+ *
This example starts a tiny local async HTTP server that:
+ *
+ * - sends a {@code 103} informational response with two {@code Link} headers, then
+ * - completes the exchange with a final {@code 200 OK} and a short body.
+ *
+ * The async client registers an Early Hints listener, prints any received {@code 103}
+ * headers, and then prints the final status and body.
+ *
+ * Use this sample to see how to wire {@code setEarlyHintsListener(...)} and verify that
+ * Early Hints do not interfere with normal response processing.
+ */
+
+public class AsyncClientEarlyHintsEndToEnd {
+
+ public static void main(final String[] args) throws Exception {
+ // --- Start minimal async server that sends 103 then 200
+ final HttpAsyncServer server = AsyncServerBootstrap.bootstrap()
+ .setCanonicalHostName("localhost")
+ .register("/eh", () -> new AsyncServerExchangeHandler() {
+
+ private final byte[] body = "OK".getBytes(StandardCharsets.US_ASCII);
+ private volatile boolean sent;
+
+ @Override
+ public void handleRequest(final HttpRequest request,
+ final EntityDetails entityDetails,
+ final ResponseChannel channel,
+ final HttpContext context)
+ throws HttpException, IOException {
+
+ // 103 Early Hints
+ final BasicHttpResponse hints = new BasicHttpResponse(HttpStatus.SC_EARLY_HINTS);
+ hints.addHeader("Link", "; rel=preload; as=style");
+ hints.addHeader("Link", "; rel=preload; as=script");
+ channel.sendInformation(hints, context);
+
+ // Final 200 (announce entity; body will be produced in produce())
+ final BasicHttpResponse ok = new BasicHttpResponse(HttpStatus.SC_OK);
+ ok.addHeader("Content-Type", ContentType.TEXT_PLAIN.toString());
+ final BasicEntityDetails details = new BasicEntityDetails(body.length, ContentType.TEXT_PLAIN);
+ channel.sendResponse(ok, details, context);
+ }
+
+ // ---- AsyncDataConsumer (request body not expected)
+ @Override
+ public void updateCapacity(final CapacityChannel ch) throws IOException {
+ ch.update(Integer.MAX_VALUE);
+ }
+
+ @Override
+ public void consume(final ByteBuffer src) { /* no-op */ }
+
+ @Override
+ public void streamEnd(final List extends Header> trailers) { /* no-op */ }
+
+ // ---- AsyncDataProducer (MUST implement both of these)
+ @Override
+ public void produce(final DataStreamChannel ch) throws IOException {
+ if (!sent) {
+ ch.write(java.nio.ByteBuffer.wrap(body));
+ ch.endStream();
+ sent = true;
+ }
+ }
+
+ @Override
+ public int available() {
+ return sent ? 0 : body.length;
+ }
+
+ @Override
+ public void failed(final Exception cause) { /* no-op for demo */ }
+
+ @Override
+ public void releaseResources() { /* no-op for demo */ }
+ })
+ .create();
+ server.start();
+ final Future lf = server.listen(new InetSocketAddress(0), URIScheme.HTTP);
+ final int port = ((InetSocketAddress) lf.get().getAddress()).getPort();
+
+ // --- Async client with Early Hints listener
+ final EarlyHintsListener hintsListener = (hints, ctx) -> {
+ System.out.println("[client] Early Hints 103:");
+ for (final Header h : hints.getHeaders("Link")) {
+ System.out.println(" " + h.getValue());
+ }
+ };
+
+ try (final CloseableHttpAsyncClient client = HttpAsyncClients.custom()
+ .setConnectionManager(
+ PoolingAsyncClientConnectionManagerBuilder.create()
+ .setDefaultTlsConfig(TlsConfig.DEFAULT) // plain HTTP here; keep TLS config for real targets
+ .build())
+ .setEarlyHintsListener(hintsListener)
+ .build()) {
+ client.start();
+
+ final SimpleHttpRequest req = SimpleRequestBuilder.get("http://localhost:" + port + "/eh").build();
+ final SimpleHttpResponse resp = client.execute(req, null).get(5, TimeUnit.SECONDS);
+
+ System.out.println("[client] final: " + resp.getCode() + " " + resp.getReasonPhrase());
+ System.out.println("[client] body: " + resp.getBodyText());
+ } finally {
+ server.close(CloseMode.GRACEFUL);
+ }
+ }
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/EarlyHintsAsyncExecTest.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/EarlyHintsAsyncExecTest.java
new file mode 100644
index 0000000000..ed09da6ef4
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/EarlyHintsAsyncExecTest.java
@@ -0,0 +1,206 @@
+/*
+ * ====================================================================
+ * 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 static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.hc.client5.http.EarlyHintsListener;
+import org.apache.hc.client5.http.async.methods.SimpleHttpResponse;
+import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
+import org.apache.hc.core5.http.ContentType;
+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.HttpStatus;
+import org.apache.hc.core5.http.URIScheme;
+import org.apache.hc.core5.http.impl.BasicEntityDetails;
+import org.apache.hc.core5.http.impl.bootstrap.AsyncServerBootstrap;
+import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncServer;
+import org.apache.hc.core5.http.message.BasicHttpResponse;
+import org.apache.hc.core5.http.nio.AsyncServerExchangeHandler;
+import org.apache.hc.core5.http.nio.CapacityChannel;
+import org.apache.hc.core5.http.nio.DataStreamChannel;
+import org.apache.hc.core5.http.nio.ResponseChannel;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.reactor.IOReactorConfig;
+import org.apache.hc.core5.reactor.ListenerEndpoint;
+import org.apache.hc.core5.util.Timeout;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+
+class EarlyHintsAsyncExecTest {
+
+ private HttpAsyncServer server;
+
+ @AfterEach
+ void tearDown() {
+ if (server != null) {
+ server.close(CloseMode.GRACEFUL);
+ }
+ }
+
+ @Test
+ @org.junit.jupiter.api.Timeout(10)
+ void async_client_receives_103_early_hints_and_final_200() throws Exception {
+ server = AsyncServerBootstrap.bootstrap()
+ .setCanonicalHostName("localhost")
+ .setIOReactorConfig(IOReactorConfig.custom()
+ .setSoTimeout(Timeout.ofSeconds(5))
+ .build())
+ .register("/eh", () -> new AsyncServerExchangeHandler() {
+
+ private final byte[] body = "OK".getBytes(StandardCharsets.US_ASCII);
+ private volatile boolean sentBody;
+
+ @Override
+ public void handleRequest(
+ final HttpRequest request,
+ final EntityDetails entityDetails,
+ final ResponseChannel channel,
+ final HttpContext context) throws HttpException {
+ // Send 103 Early Hints
+ final BasicHttpResponse hints = new BasicHttpResponse(HttpStatus.SC_EARLY_HINTS);
+ hints.addHeader("Link", "; rel=preload; as=style");
+ hints.addHeader("Link", "; rel=preload; as=script");
+ try {
+ channel.sendInformation(hints, context);
+ } catch (final Exception ex) {
+ throw new HttpException(ex.getMessage(), ex);
+ }
+
+ // Send final 200 response head; body via produce()
+ final BasicHttpResponse ok = new BasicHttpResponse(HttpStatus.SC_OK);
+ ok.addHeader("Content-Type", ContentType.TEXT_PLAIN.toString());
+ final BasicEntityDetails details =
+ new BasicEntityDetails(body.length, ContentType.TEXT_PLAIN);
+ try {
+ channel.sendResponse(ok, details, context);
+ } catch (final Exception ex) {
+ throw new HttpException(ex.getMessage(), ex);
+ }
+ }
+
+ @Override
+ public void updateCapacity(final CapacityChannel capacityChannel) throws IOException {
+ capacityChannel.update(Integer.MAX_VALUE);
+ }
+
+ @Override
+ public void consume(final ByteBuffer src) { /* no-op */ }
+
+ @Override
+ public void streamEnd(final List extends Header> trailers) { /* no-op */ }
+
+ // ---- AsyncDataProducer
+ @Override
+ public void produce(final DataStreamChannel channel) throws IOException {
+ if (!sentBody) {
+ channel.write(ByteBuffer.wrap(body));
+ channel.endStream();
+ sentBody = true;
+ }
+ }
+
+ @Override
+ public int available() {
+ return sentBody ? 0 : body.length;
+ }
+
+ @Override
+ public void failed(final Exception cause) { /* no-op for test */ }
+
+ @Override
+ public void releaseResources() { /* no-op for test */ }
+ })
+ .create();
+
+ server.start();
+
+ // Bind to ephemeral port and retrieve it from the listener endpoint
+ final Future lf = server.listen(new InetSocketAddress(0), URIScheme.HTTP);
+ final ListenerEndpoint ep = lf.get(5, TimeUnit.SECONDS);
+ final int port = ((InetSocketAddress) ep.getAddress()).getPort();
+
+ final AtomicInteger hintsCount = new AtomicInteger();
+ final AtomicReference> linkHeaders = new AtomicReference<>(new ArrayList<>());
+
+ final EarlyHintsListener listener = (hints, ctx) -> {
+ if (hints.getCode() == HttpStatus.SC_EARLY_HINTS) {
+ hintsCount.incrementAndGet();
+ final Header[] hs = hints.getHeaders("Link");
+ final ArrayList vals = new ArrayList(hs.length);
+ for (final Header h : hs) {
+ vals.add(h.getValue());
+ }
+ linkHeaders.set(vals);
+ }
+ };
+
+ try (final CloseableHttpAsyncClient client = HttpAsyncClients.custom()
+ .setEarlyHintsListener(listener)
+ .build()) {
+
+ client.start();
+
+ final SimpleHttpResponse resp = client.execute(
+ SimpleRequestBuilder.get("http://localhost:" + port + "/eh").build(),
+ null).get(5, TimeUnit.SECONDS);
+
+ assertEquals(HttpStatus.SC_OK, resp.getCode(), "Final response must be 200");
+ assertEquals("OK", resp.getBodyText());
+ }
+
+ assertEquals(1, hintsCount.get(), "Expected exactly one 103 Early Hints callback");
+ final List links = linkHeaders.get();
+ boolean hasCss = false, hasJs = false;
+ for (final String v : links) {
+ if (v.contains("")) {
+ hasCss = true;
+ }
+ if (v.contains("")) {
+ hasJs = true;
+ }
+ }
+ assertTrue(hasCss, "Missing style preload link");
+ assertTrue(hasJs, "Missing script preload link");
+ }
+}