diff --git a/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java b/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java index 216dc4ed67..cd61724865 100644 --- a/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java +++ b/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java @@ -277,6 +277,11 @@ public interface AsyncHttpClientConfig { */ boolean isFilterInsecureCipherSuites(); + /** + * @return true if HTTP/2 is enabled (negotiated via ALPN for HTTPS connections) + */ + boolean isHttp2Enabled(); + /** * @return the size of the SSL session cache, 0 means using the default value */ diff --git a/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java b/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java index 1c7dbf37f8..bb6881c8aa 100644 --- a/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java +++ b/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java @@ -166,6 +166,7 @@ public class DefaultAsyncHttpClientConfig implements AsyncHttpClientConfig { private final int sslSessionTimeout; private final @Nullable SslContext sslContext; private final @Nullable SslEngineFactory sslEngineFactory; + private final boolean http2Enabled; // filters private final List requestFilters; @@ -253,6 +254,7 @@ private DefaultAsyncHttpClientConfig(// http int sslSessionTimeout, @Nullable SslContext sslContext, @Nullable SslEngineFactory sslEngineFactory, + boolean http2Enabled, // filters List requestFilters, @@ -348,6 +350,7 @@ private DefaultAsyncHttpClientConfig(// http this.sslSessionTimeout = sslSessionTimeout; this.sslContext = sslContext; this.sslEngineFactory = sslEngineFactory; + this.http2Enabled = http2Enabled; // filters this.requestFilters = requestFilters; @@ -608,6 +611,11 @@ public boolean isFilterInsecureCipherSuites() { return filterInsecureCipherSuites; } + @Override + public boolean isHttp2Enabled() { + return http2Enabled; + } + @Override public int getSslSessionCacheSize() { return sslSessionCacheSize; @@ -847,6 +855,7 @@ public static class Builder { private int sslSessionTimeout = defaultSslSessionTimeout(); private @Nullable SslContext sslContext; private @Nullable SslEngineFactory sslEngineFactory; + private boolean http2Enabled = true; // cookie store private CookieStore cookieStore = new ThreadSafeCookieStore(); @@ -939,6 +948,7 @@ public Builder(AsyncHttpClientConfig config) { sslSessionTimeout = config.getSslSessionTimeout(); sslContext = config.getSslContext(); sslEngineFactory = config.getSslEngineFactory(); + http2Enabled = config.isHttp2Enabled(); // filters requestFilters.addAll(config.getRequestFilters()); @@ -1254,6 +1264,11 @@ public Builder setSslEngineFactory(SslEngineFactory sslEngineFactory) { return this; } + public Builder setHttp2Enabled(boolean http2Enabled) { + this.http2Enabled = http2Enabled; + return this; + } + // filters public Builder addRequestFilter(RequestFilter requestFilter) { requestFilters.add(requestFilter); @@ -1486,6 +1501,7 @@ public DefaultAsyncHttpClientConfig build() { sslSessionTimeout, sslContext, sslEngineFactory, + http2Enabled, requestFilters.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(requestFilters), responseFilters.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(responseFilters), ioExceptionFilters.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(ioExceptionFilters), diff --git a/client/src/main/java/org/asynchttpclient/HttpProtocol.java b/client/src/main/java/org/asynchttpclient/HttpProtocol.java new file mode 100644 index 0000000000..4d4d8e3309 --- /dev/null +++ b/client/src/main/java/org/asynchttpclient/HttpProtocol.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2014-2026 AsyncHttpClient Project. All rights reserved. + * + * Licensed 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. + */ +package org.asynchttpclient; + +/** + * HTTP protocol version used for a request/response exchange. + */ +public enum HttpProtocol { + + HTTP_1_0("HTTP/1.0"), + HTTP_1_1("HTTP/1.1"), + HTTP_2("HTTP/2.0"); + + private final String text; + + HttpProtocol(String text) { + this.text = text; + } + + /** + * @return the protocol version string (e.g. "HTTP/1.1", "HTTP/2.0") + */ + public String getText() { + return text; + } + + @Override + public String toString() { + return text; + } +} diff --git a/client/src/main/java/org/asynchttpclient/Response.java b/client/src/main/java/org/asynchttpclient/Response.java index 220d989b09..77512094d2 100644 --- a/client/src/main/java/org/asynchttpclient/Response.java +++ b/client/src/main/java/org/asynchttpclient/Response.java @@ -169,6 +169,15 @@ public interface Response { */ boolean hasResponseBody(); + /** + * Return the HTTP protocol version used for this response. + * + * @return the protocol, defaults to {@link HttpProtocol#HTTP_1_1} + */ + default HttpProtocol getProtocol() { + return HttpProtocol.HTTP_1_1; + } + /** * Get the remote address that the client initiated the request to. * diff --git a/client/src/main/java/org/asynchttpclient/netty/NettyResponse.java b/client/src/main/java/org/asynchttpclient/netty/NettyResponse.java index 61fb15161c..31c7cb2f04 100755 --- a/client/src/main/java/org/asynchttpclient/netty/NettyResponse.java +++ b/client/src/main/java/org/asynchttpclient/netty/NettyResponse.java @@ -22,6 +22,7 @@ import io.netty.handler.codec.http.HttpHeaders; import io.netty.handler.codec.http.cookie.ClientCookieDecoder; import io.netty.handler.codec.http.cookie.Cookie; +import org.asynchttpclient.HttpProtocol; import org.asynchttpclient.HttpResponseBodyPart; import org.asynchttpclient.HttpResponseStatus; import org.asynchttpclient.Response; @@ -158,6 +159,20 @@ public List getCookies() { } + @Override + public HttpProtocol getProtocol() { + if (status == null) { + return HttpProtocol.HTTP_1_1; + } + int major = status.getProtocolMajorVersion(); + if (major == 2) { + return HttpProtocol.HTTP_2; + } else if (status.getProtocolMinorVersion() == 0) { + return HttpProtocol.HTTP_1_0; + } + return HttpProtocol.HTTP_1_1; + } + @Override public boolean hasResponseStatus() { return status != null; @@ -223,6 +238,7 @@ public InputStream getResponseBodyAsStream() { public String toString() { StringBuilder sb = new StringBuilder(); sb.append(getClass().getSimpleName()).append(" {\n") + .append("\tprotocol=").append(getProtocol()).append('\n') .append("\tstatusCode=").append(getStatusCode()).append('\n') .append("\theaders=\n"); diff --git a/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java b/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java index e59daadd1c..a4b9528386 100755 --- a/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java +++ b/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java @@ -34,6 +34,10 @@ import io.netty.handler.codec.http.websocketx.WebSocket08FrameEncoder; import io.netty.handler.codec.http.websocketx.WebSocketFrameAggregator; import io.netty.handler.codec.http.websocketx.extensions.compression.WebSocketClientCompressionHandler; +import io.netty.handler.codec.http2.Http2FrameCodec; +import io.netty.handler.codec.http2.Http2FrameCodecBuilder; +import io.netty.handler.codec.http2.Http2MultiplexHandler; +import io.netty.handler.codec.http2.Http2Settings; import io.netty.handler.logging.LogLevel; import io.netty.handler.logging.LoggingHandler; import io.netty.handler.proxy.ProxyHandler; @@ -61,6 +65,7 @@ import org.asynchttpclient.netty.NettyResponseFuture; import org.asynchttpclient.netty.OnLastHttpContentCallback; import org.asynchttpclient.netty.handler.AsyncHttpClientHandler; +import org.asynchttpclient.netty.handler.Http2Handler; import org.asynchttpclient.netty.handler.HttpHandler; import org.asynchttpclient.netty.handler.WebSocketHandler; import org.asynchttpclient.netty.request.NettyRequestSender; @@ -96,6 +101,9 @@ public class ChannelManager { public static final String AHC_HTTP_HANDLER = "ahc-http"; public static final String AHC_WS_HANDLER = "ahc-ws"; public static final String LOGGING_HANDLER = "logging"; + public static final String HTTP2_FRAME_CODEC = "http2-frame-codec"; + public static final String HTTP2_MULTIPLEX = "http2-multiplex"; + public static final String AHC_HTTP2_HANDLER = "ahc-http2"; private static final Logger LOGGER = LoggerFactory.getLogger(ChannelManager.class); private final AsyncHttpClientConfig config; private final SslEngineFactory sslEngineFactory; @@ -109,6 +117,7 @@ public class ChannelManager { private final ChannelGroup openChannels; private AsyncHttpClientHandler wsHandler; + private Http2Handler http2Handler; private boolean isInstanceof(Object object, String name) { final Class clazz; @@ -239,6 +248,7 @@ private static Bootstrap newBootstrap(ChannelFactory channelF public void configureBootstraps(NettyRequestSender requestSender) { final AsyncHttpClientHandler httpHandler = new HttpHandler(config, this, requestSender); wsHandler = new WebSocketHandler(config, this, requestSender); + http2Handler = new Http2Handler(config, this, requestSender); httpBootstrap.handler(new ChannelInitializer() { @Override @@ -549,6 +559,58 @@ protected void initChannel(Channel channel) throws Exception { return promise; } + /** + * Checks whether the given channel is an HTTP/2 connection (i.e. has the HTTP/2 multiplex handler installed). + */ + public static boolean isHttp2(Channel channel) { + return channel.pipeline().get(HTTP2_MULTIPLEX) != null; + } + + /** + * Returns the shared {@link Http2Handler} instance for use with stream child channels. + */ + public Http2Handler getHttp2Handler() { + return http2Handler; + } + + /** + * Upgrades the pipeline from HTTP/1.1 to HTTP/2 after ALPN negotiates "h2". + * Removes HTTP/1.1 handlers and adds {@link Http2FrameCodec} + {@link Http2MultiplexHandler}. + * The per-stream {@link Http2Handler} is added separately on each stream child channel. + */ + public void upgradePipelineToHttp2(ChannelPipeline pipeline) { + // Remove HTTP/1.1 specific handlers + if (pipeline.get(HTTP_CLIENT_CODEC) != null) { + pipeline.remove(HTTP_CLIENT_CODEC); + } + if (pipeline.get(INFLATER_HANDLER) != null) { + pipeline.remove(INFLATER_HANDLER); + } + if (pipeline.get(CHUNKED_WRITER_HANDLER) != null) { + pipeline.remove(CHUNKED_WRITER_HANDLER); + } + if (pipeline.get(AHC_HTTP_HANDLER) != null) { + pipeline.remove(AHC_HTTP_HANDLER); + } + + // Add HTTP/2 frame codec (handles connection preface, SETTINGS, PING, flow control, etc.) + Http2FrameCodec frameCodec = Http2FrameCodecBuilder.forClient() + .initialSettings(Http2Settings.defaultSettings()) + .build(); + + // Http2MultiplexHandler creates a child channel per HTTP/2 stream. + // Server-push streams are silently ignored (no-op initializer) since AHC is client-only. + Http2MultiplexHandler multiplexHandler = new Http2MultiplexHandler(new ChannelInitializer() { + @Override + protected void initChannel(Channel ch) { + // Server push not supported — ignore inbound pushed streams + } + }); + + pipeline.addLast(HTTP2_FRAME_CODEC, frameCodec); + pipeline.addLast(HTTP2_MULTIPLEX, multiplexHandler); + } + public void upgradePipelineForWebSockets(ChannelPipeline pipeline) { pipeline.addAfter(HTTP_CLIENT_CODEC, WS_ENCODER_HANDLER, new WebSocket08FrameEncoder(true)); pipeline.addAfter(WS_ENCODER_HANDLER, WS_DECODER_HANDLER, new WebSocket08FrameDecoder(false, diff --git a/client/src/main/java/org/asynchttpclient/netty/channel/NettyConnectListener.java b/client/src/main/java/org/asynchttpclient/netty/channel/NettyConnectListener.java index a1d61177eb..137c306268 100755 --- a/client/src/main/java/org/asynchttpclient/netty/channel/NettyConnectListener.java +++ b/client/src/main/java/org/asynchttpclient/netty/channel/NettyConnectListener.java @@ -17,6 +17,7 @@ import io.netty.channel.Channel; import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.ssl.ApplicationProtocolNames; import io.netty.handler.ssl.SslHandler; import org.asynchttpclient.AsyncHandler; import org.asynchttpclient.Request; @@ -185,6 +186,11 @@ protected void onSuccess(Channel value) { NettyConnectListener.this.onFailure(channel, e); return; } + // Detect ALPN-negotiated protocol and upgrade pipeline to HTTP/2 if "h2" was selected + String alpnProtocol = sslHandler.applicationProtocol(); + if (ApplicationProtocolNames.HTTP_2.equals(alpnProtocol)) { + channelManager.upgradePipelineToHttp2(channel.pipeline()); + } writeRequest(channel); } diff --git a/client/src/main/java/org/asynchttpclient/netty/handler/Http2Handler.java b/client/src/main/java/org/asynchttpclient/netty/handler/Http2Handler.java new file mode 100644 index 0000000000..dc06f85be7 --- /dev/null +++ b/client/src/main/java/org/asynchttpclient/netty/handler/Http2Handler.java @@ -0,0 +1,219 @@ +/* + * Copyright (c) 2014-2026 AsyncHttpClient Project. All rights reserved. + * + * Licensed 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. + */ +package org.asynchttpclient.netty.handler; + +import io.netty.buffer.ByteBuf; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandler.Sharable; +import io.netty.handler.codec.http.DefaultHttpHeaders; +import io.netty.handler.codec.http.DefaultHttpResponse; +import io.netty.handler.codec.http.HttpHeaders; +import io.netty.handler.codec.http.HttpResponse; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpVersion; +import io.netty.handler.codec.http2.Http2DataFrame; +import io.netty.handler.codec.http2.Http2Headers; +import io.netty.handler.codec.http2.Http2HeadersFrame; +import io.netty.handler.codec.http2.Http2ResetFrame; +import io.netty.handler.codec.http2.Http2StreamChannel; +import org.asynchttpclient.AsyncHandler; +import org.asynchttpclient.AsyncHandler.State; +import org.asynchttpclient.AsyncHttpClientConfig; +import org.asynchttpclient.HttpResponseBodyPart; +import org.asynchttpclient.netty.NettyResponseFuture; +import org.asynchttpclient.netty.NettyResponseStatus; +import org.asynchttpclient.netty.channel.ChannelManager; +import org.asynchttpclient.netty.request.NettyRequestSender; + +import java.io.IOException; + +/** + * HTTP/2 channel handler for stream child channels created by {@link io.netty.handler.codec.http2.Http2MultiplexHandler}. + *

+ * Each HTTP/2 stream is represented as a child channel. This handler is attached to each stream child channel + * and processes {@link Http2HeadersFrame} (response status + headers) and {@link Http2DataFrame} (response body) + * frames directly for maximum performance — no HTTP/1.1 object conversion overhead. + *

+ * Follows the same structure as {@link HttpHandler} and reuses the same interceptor chain, + * body part factory, and lifecycle methods from {@link AsyncHttpClientHandler}. + */ +@Sharable +public final class Http2Handler extends AsyncHttpClientHandler { + + private static final HttpVersion HTTP_2 = new HttpVersion("HTTP", 2, 0, true); + + public Http2Handler(AsyncHttpClientConfig config, ChannelManager channelManager, NettyRequestSender requestSender) { + super(config, channelManager, requestSender); + } + + /** + * Handles incoming frames on the HTTP/2 stream child channel. + * Dispatches to the appropriate handler based on frame type. + */ + @Override + public void handleRead(final Channel channel, final NettyResponseFuture future, final Object e) throws Exception { + if (future.isDone()) { + channelManager.closeChannel(channel); + return; + } + + AsyncHandler handler = future.getAsyncHandler(); + try { + if (e instanceof Http2HeadersFrame) { + handleHttp2HeadersFrame((Http2HeadersFrame) e, channel, future, handler); + } else if (e instanceof Http2DataFrame) { + handleHttp2DataFrame((Http2DataFrame) e, channel, future, handler); + } else if (e instanceof Http2ResetFrame) { + handleHttp2ResetFrame((Http2ResetFrame) e, channel, future); + } + } catch (Exception t) { + if (hasIOExceptionFilters && t instanceof IOException + && requestSender.applyIoExceptionFiltersAndReplayRequest(future, (IOException) t, channel)) { + return; + } + readFailed(channel, future, t); + throw t; + } + } + + /** + * Processes an HTTP/2 HEADERS frame, which carries the response status and headers. + * Builds a synthetic {@link HttpResponse} from the HTTP/2 pseudo-headers so the existing + * interceptor chain can be reused without modification. + */ + private void handleHttp2HeadersFrame(Http2HeadersFrame headersFrame, Channel channel, + NettyResponseFuture future, AsyncHandler handler) throws Exception { + Http2Headers h2Headers = headersFrame.headers(); + + // Extract :status pseudo-header and convert to HTTP status + CharSequence statusValue = h2Headers.status(); + int statusCode = statusValue != null ? Integer.parseInt(statusValue.toString()) : 200; + HttpResponseStatus nettyStatus = HttpResponseStatus.valueOf(statusCode); + + // Build HTTP/1.1-style headers, skipping HTTP/2 pseudo-headers (start with ':') + HttpHeaders responseHeaders = new DefaultHttpHeaders(false); + h2Headers.forEach(entry -> { + CharSequence name = entry.getKey(); + if (name.length() > 0 && name.charAt(0) != ':') { + responseHeaders.add(name, entry.getValue()); + } + }); + + // Build a synthetic HttpResponse so the existing interceptor chain can be reused unchanged + HttpResponse syntheticResponse = new DefaultHttpResponse(HTTP_2, nettyStatus, responseHeaders); + + // Respect user's keepAlive config; only multiplex/pool if keepAlive is enabled + future.setKeepAlive(config.isKeepAlive()); + + NettyResponseStatus status = new NettyResponseStatus(future.getUri(), syntheticResponse, channel); + + if (!interceptors.exitAfterIntercept(channel, future, handler, syntheticResponse, status, responseHeaders)) { + boolean abort = handler.onStatusReceived(status) == State.ABORT; + if (!abort && !responseHeaders.isEmpty()) { + abort = handler.onHeadersReceived(responseHeaders) == State.ABORT; + } + if (abort) { + finishUpdate(future, channel, false); + return; + } + } + + // If headers frame also ends the stream (no body), finish the response + if (headersFrame.isEndStream()) { + finishUpdate(future, channel, false); + } + } + + /** + * Processes an HTTP/2 DATA frame, which carries response body bytes. + * Passes body content directly to {@link AsyncHandler#onBodyPartReceived} using the + * configured {@link org.asynchttpclient.ResponseBodyPartFactory} — same as HTTP/1.1. + */ + private void handleHttp2DataFrame(Http2DataFrame dataFrame, Channel channel, + NettyResponseFuture future, AsyncHandler handler) throws Exception { + boolean last = dataFrame.isEndStream(); + ByteBuf data = dataFrame.content(); + + if (data.isReadable() || last) { + HttpResponseBodyPart bodyPart = config.getResponseBodyPartFactory().newResponseBodyPart(data, last); + boolean abort = handler.onBodyPartReceived(bodyPart) == State.ABORT; + if (abort || last) { + finishUpdate(future, channel, false); + } + } + } + + /** + * Processes an HTTP/2 RST_STREAM frame, which indicates the server aborted the stream. + */ + private void handleHttp2ResetFrame(Http2ResetFrame resetFrame, Channel channel, NettyResponseFuture future) { + long errorCode = resetFrame.errorCode(); + readFailed(channel, future, new IOException("HTTP/2 stream reset by server, error code: " + errorCode)); + } + + /** + * Overrides the base {@link AsyncHttpClientHandler#finishUpdate} to correctly handle HTTP/2 + * connection pooling. HTTP/2 stream channels are single-use — after the stream completes, + * it must be closed. The reusable resource is the parent TCP connection channel, which is + * offered back to the pool so future requests can open new streams on the same connection. + * + * @param future the completed request future + * @param streamChannel the stream child channel (single-use, will be closed) + * @param close if {@code true}, close the parent connection entirely rather than pooling it + */ + @Override + void finishUpdate(NettyResponseFuture future, Channel streamChannel, boolean close) { + future.cancelTimeouts(); + + // Stream channels are single-use in HTTP/2 — close the stream + streamChannel.close(); + + // Offer the parent connection back to the pool for connection reuse (multiplexing) + Channel parentChannel = (streamChannel instanceof Http2StreamChannel) + ? ((Http2StreamChannel) streamChannel).parent() + : null; + + if (!close && future.isKeepAlive() && parentChannel != null && parentChannel.isActive()) { + channelManager.tryToOfferChannelToPool(parentChannel, future.getAsyncHandler(), true, future.getPartitionKey()); + } else if (parentChannel != null) { + channelManager.closeChannel(parentChannel); + } + + try { + future.done(); + } catch (Exception t) { + logger.debug(t.getMessage(), t); + } + } + + private void readFailed(Channel channel, NettyResponseFuture future, Throwable t) { + try { + requestSender.abort(channel, future, t); + } catch (Exception abortException) { + logger.debug("Abort failed", abortException); + } finally { + finishUpdate(future, channel, true); + } + } + + @Override + public void handleException(NettyResponseFuture future, Throwable error) { + } + + @Override + public void handleChannelInactive(NettyResponseFuture future) { + } +} diff --git a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java index c929d35e27..b35f66ca5f 100755 --- a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java +++ b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java @@ -16,15 +16,25 @@ package org.asynchttpclient.netty.request; import io.netty.bootstrap.Bootstrap; +import io.netty.buffer.ByteBuf; import io.netty.channel.Channel; import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelInitializer; import io.netty.channel.ChannelProgressivePromise; import io.netty.channel.ChannelPromise; +import io.netty.handler.codec.http.DefaultFullHttpRequest; import io.netty.handler.codec.http.DefaultHttpHeaders; import io.netty.handler.codec.http.HttpHeaderValues; import io.netty.handler.codec.http.HttpHeaders; import io.netty.handler.codec.http.HttpMethod; import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.codec.http2.DefaultHttp2DataFrame; +import io.netty.handler.codec.http2.DefaultHttp2Headers; +import io.netty.handler.codec.http2.DefaultHttp2HeadersFrame; +import io.netty.handler.codec.http2.Http2Headers; +import io.netty.handler.codec.http2.Http2StreamChannel; +import io.netty.handler.codec.http2.Http2StreamChannelBootstrap; +import io.netty.util.ReferenceCountUtil; import io.netty.util.Timer; import io.netty.util.concurrent.Future; import io.netty.util.concurrent.ImmediateEventExecutor; @@ -52,6 +62,7 @@ import org.asynchttpclient.netty.channel.DefaultConnectionSemaphoreFactory; import org.asynchttpclient.netty.channel.NettyChannelConnector; import org.asynchttpclient.netty.channel.NettyConnectListener; +import org.asynchttpclient.netty.request.body.NettyBody; import org.asynchttpclient.netty.timeout.TimeoutsHolder; import org.asynchttpclient.proxy.ProxyServer; import org.asynchttpclient.proxy.ProxyType; @@ -65,14 +76,17 @@ import java.net.InetSocketAddress; import java.net.SocketAddress; import java.util.List; +import java.util.Set; import static io.netty.handler.codec.http.HttpHeaderNames.EXPECT; import static java.util.Collections.singletonList; import static java.util.Objects.requireNonNull; +import static java.util.Set.of; import static org.asynchttpclient.util.AuthenticatorUtils.perConnectionAuthorizationHeader; import static org.asynchttpclient.util.AuthenticatorUtils.perConnectionProxyAuthorizationHeader; import static org.asynchttpclient.util.HttpConstants.Methods.CONNECT; import static org.asynchttpclient.util.HttpConstants.Methods.GET; +import static org.asynchttpclient.util.HttpUtils.hostHeader; import static org.asynchttpclient.util.MiscUtils.getCause; import static org.asynchttpclient.util.ProxyUtils.getProxyServer; @@ -375,18 +389,31 @@ private NettyResponseFuture newNettyResponseFuture(Request request, Async return future; } - public void writeRequest(NettyResponseFuture future, Channel channel) { - NettyRequest nettyRequest = future.getNettyRequest(); - HttpRequest httpRequest = nettyRequest.getHttpRequest(); - AsyncHandler asyncHandler = future.getAsyncHandler(); + /** + * HTTP/2 connection-specific headers that must NOT be forwarded as per RFC 7540 §8.1.2.2. + * These are HTTP/1.1 connection-specific headers that have no meaning in HTTP/2. + */ + private static final Set HTTP2_EXCLUDED_HEADERS = of( + "connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade", "host" + ); - // if the channel is dead because it was pooled and the remote server decided to - // close it, + public void writeRequest(NettyResponseFuture future, Channel channel) { + // if the channel is dead because it was pooled and the remote server decided to close it, // we just let it go and the channelInactive do its work if (!Channels.isChannelActive(channel)) { return; } + // Route to HTTP/2 path if the parent channel has the HTTP/2 multiplex handler installed + if (ChannelManager.isHttp2(channel)) { + writeHttp2Request(future, channel); + return; + } + + NettyRequest nettyRequest = future.getNettyRequest(); + HttpRequest httpRequest = nettyRequest.getHttpRequest(); + AsyncHandler asyncHandler = future.getAsyncHandler(); + try { if (asyncHandler instanceof TransferCompletionHandler) { configureTransferAdapter(asyncHandler, httpRequest); @@ -431,6 +458,95 @@ public void writeRequest(NettyResponseFuture future, Channel channel) { } } + /** + * Opens a new HTTP/2 stream child channel on the given parent connection channel and writes the request + * as HTTP/2 frames ({@link DefaultHttp2HeadersFrame} + optional {@link DefaultHttp2DataFrame}). + * The stream child channel has the {@link org.asynchttpclient.netty.handler.Http2Handler} installed + * and the {@link NettyResponseFuture} attached to it, mirroring the HTTP/1.1 channel model. + */ + private void writeHttp2Request(NettyResponseFuture future, Channel parentChannel) { + new Http2StreamChannelBootstrap(parentChannel) + .handler(new ChannelInitializer() { + @Override + protected void initChannel(Http2StreamChannel streamCh) { + streamCh.pipeline().addLast(channelManager.getHttp2Handler()); + } + }) + .open() + .addListener((Future f) -> { + if (f.isSuccess()) { + Http2StreamChannel streamChannel = f.getNow(); + channelManager.registerOpenChannel(streamChannel); + Channels.setAttribute(streamChannel, future); + future.attachChannel(streamChannel, false); + try { + sendHttp2Frames(future, streamChannel); + scheduleReadTimeout(future); + } catch (Exception e) { + LOGGER.error("Can't write HTTP/2 request", e); + abort(parentChannel, future, e); + } + } else { + abort(parentChannel, future, f.cause()); + } + }); + } + + /** + * Builds and writes HTTP/2 frames for the given request on the stream child channel. + *

+ * Manually assembles {@link DefaultHttp2Headers} with HTTP/2 pseudo-headers (:method, :path, + * :scheme, :authority) plus all regular request headers, then writes them as a + * {@link DefaultHttp2HeadersFrame}. If the request has a body, writes it as a + * {@link DefaultHttp2DataFrame} with {@code endStream=true}. + */ + private void sendHttp2Frames(NettyResponseFuture future, Http2StreamChannel streamChannel) { + NettyRequest nettyRequest = future.getNettyRequest(); + HttpRequest httpRequest = nettyRequest.getHttpRequest(); + Uri uri = future.getUri(); + + // Build HTTP/2 pseudo-headers + regular headers + Http2Headers h2Headers = new DefaultHttp2Headers(false) + .method(httpRequest.method().name()) + .path(uri.getNonEmptyPath() + (uri.getQuery() != null ? "?" + uri.getQuery() : "")) + .scheme(uri.getScheme()) + .authority(hostHeader(uri)); + + // Copy HTTP/1.1 headers, skipping connection-specific ones that are forbidden in HTTP/2. + // RFC 7540 §8.1.2 requires all header field names to be lowercase in HTTP/2. + httpRequest.headers().forEach(entry -> { + String name = entry.getKey().toLowerCase(); + if (!HTTP2_EXCLUDED_HEADERS.contains(name)) { + h2Headers.add(name, entry.getValue()); + } + }); + + // Determine if we have a body to write + ByteBuf bodyBuf = null; + if (httpRequest instanceof DefaultFullHttpRequest) { + ByteBuf content = ((DefaultFullHttpRequest) httpRequest).content(); + if (content != null && content.isReadable()) { + bodyBuf = content; + } + } + + boolean hasBody = bodyBuf != null; + + // Write HEADERS frame (endStream=true when there is no body) + streamChannel.write(new DefaultHttp2HeadersFrame(h2Headers, !hasBody)); + + if (hasBody) { + // Write DATA frame with endStream=true — body is sent as a single frame + streamChannel.write(new DefaultHttp2DataFrame(bodyBuf.retainedDuplicate(), true)); + } + + streamChannel.flush(); + + // Release the original HTTP/1.1 request — in the HTTP/2 path it is not written to the channel, + // so we must release it manually to avoid leaking its content ByteBuf. + ReferenceCountUtil.release(httpRequest); + } + private static void configureTransferAdapter(AsyncHandler handler, HttpRequest httpRequest) { HttpHeaders h = new DefaultHttpHeaders().set(httpRequest.headers()); ((TransferCompletionHandler) handler).headers(h); diff --git a/client/src/main/java/org/asynchttpclient/netty/ssl/DefaultSslEngineFactory.java b/client/src/main/java/org/asynchttpclient/netty/ssl/DefaultSslEngineFactory.java index 323b75d5d2..da0feae8cd 100644 --- a/client/src/main/java/org/asynchttpclient/netty/ssl/DefaultSslEngineFactory.java +++ b/client/src/main/java/org/asynchttpclient/netty/ssl/DefaultSslEngineFactory.java @@ -16,6 +16,8 @@ package org.asynchttpclient.netty.ssl; import io.netty.buffer.ByteBufAllocator; +import io.netty.handler.ssl.ApplicationProtocolConfig; +import io.netty.handler.ssl.ApplicationProtocolNames; import io.netty.handler.ssl.IdentityCipherSuiteFilter; import io.netty.handler.ssl.SslContext; import io.netty.handler.ssl.SslContextBuilder; @@ -61,6 +63,15 @@ private SslContext buildSslContext(AsyncHttpClientConfig config) throws SSLExcep sslContextBuilder.endpointIdentificationAlgorithm( config.isDisableHttpsEndpointIdentificationAlgorithm() ? "" : "HTTPS"); + if (config.isHttp2Enabled()) { + sslContextBuilder.applicationProtocolConfig(new ApplicationProtocolConfig( + ApplicationProtocolConfig.Protocol.ALPN, + ApplicationProtocolConfig.SelectorFailureBehavior.NO_ADVERTISE, + ApplicationProtocolConfig.SelectedListenerFailureBehavior.ACCEPT, + ApplicationProtocolNames.HTTP_2, + ApplicationProtocolNames.HTTP_1_1)); + } + return configureSslContextBuilder(sslContextBuilder).build(); } diff --git a/client/src/test/java/org/asynchttpclient/BasicHttp2Test.java b/client/src/test/java/org/asynchttpclient/BasicHttp2Test.java new file mode 100644 index 0000000000..2cc103e764 --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/BasicHttp2Test.java @@ -0,0 +1,1123 @@ +/* + * Copyright (c) 2014-2026 AsyncHttpClient Project. All rights reserved. + * + * Licensed 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. + */ +package org.asynchttpclient; + +import io.netty.bootstrap.ServerBootstrap; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.group.ChannelGroup; +import io.netty.channel.group.DefaultChannelGroup; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpHeaderValues; +import io.netty.handler.codec.http.QueryStringDecoder; +import io.netty.handler.codec.http2.DefaultHttp2DataFrame; +import io.netty.handler.codec.http2.DefaultHttp2Headers; +import io.netty.handler.codec.http2.DefaultHttp2HeadersFrame; +import io.netty.handler.codec.http2.DefaultHttp2ResetFrame; +import io.netty.handler.codec.http2.Http2DataFrame; +import io.netty.handler.codec.http2.Http2Error; +import io.netty.handler.codec.http2.Http2FrameCodecBuilder; +import io.netty.util.ReferenceCountUtil; +import io.netty.handler.codec.http2.Http2Headers; +import io.netty.handler.codec.http2.Http2HeadersFrame; +import io.netty.handler.codec.http2.Http2MultiplexHandler; +import io.netty.handler.codec.http2.Http2StreamChannel; +import io.netty.handler.ssl.ApplicationProtocolConfig; +import io.netty.handler.ssl.ApplicationProtocolNames; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.ssl.util.SelfSignedCertificate; +import io.netty.util.concurrent.GlobalEventExecutor; +import org.asynchttpclient.test.EventCollectingHandler; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_TYPE; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.asynchttpclient.Dsl.asyncHttpClient; +import static org.asynchttpclient.Dsl.config; +import static org.asynchttpclient.test.TestUtils.AsyncCompletionHandlerAdapter; +import static org.asynchttpclient.util.DateUtils.unpreciseMillisTime; +import static org.asynchttpclient.util.ThrowableUtil.unknownStackTrace; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration tests for HTTP/2 support using a self-contained Netty-based HTTP/2 test server. + *

+ * The embedded server uses {@link Http2FrameCodecBuilder} and {@link Http2MultiplexHandler} on + * the server side, and tests verify that the client correctly: + *

    + *
  • Negotiates HTTP/2 via ALPN
  • + *
  • Sends requests as HTTP/2 frames ({@link Http2HeadersFrame} + {@link Http2DataFrame})
  • + *
  • Receives responses and delivers them via the normal {@link AsyncHandler} callback sequence
  • + *
  • Correctly multiplexes concurrent requests over a single connection
  • + *
  • Falls back to HTTP/1.1 when HTTP/2 is disabled
  • + *
+ */ +public class BasicHttp2Test { + + // Event constants (from HttpTest/EventCollectingHandler) + private static final String COMPLETED_EVENT = "Completed"; + private static final String STATUS_RECEIVED_EVENT = "StatusReceived"; + private static final String HEADERS_RECEIVED_EVENT = "HeadersReceived"; + private static final String HEADERS_WRITTEN_EVENT = "HeadersWritten"; + private static final String CONNECTION_OPEN_EVENT = "ConnectionOpen"; + private static final String HOSTNAME_RESOLUTION_EVENT = "HostnameResolution"; + private static final String HOSTNAME_RESOLUTION_SUCCESS_EVENT = "HostnameResolutionSuccess"; + private static final String CONNECTION_SUCCESS_EVENT = "ConnectionSuccess"; + private static final String TLS_HANDSHAKE_EVENT = "TlsHandshake"; + private static final String TLS_HANDSHAKE_SUCCESS_EVENT = "TlsHandshakeSuccess"; + private static final String CONNECTION_POOL_EVENT = "ConnectionPool"; + private static final String CONNECTION_OFFER_EVENT = "ConnectionOffer"; + private static final String REQUEST_SEND_EVENT = "RequestSend"; + + private NioEventLoopGroup serverGroup; + private Channel serverChannel; + private ChannelGroup serverChildChannels; + private SslContext serverSslCtx; + private int serverPort; + + /** + * Path-routing HTTP/2 server handler that supports multiple test scenarios. + */ + private static final class Http2TestServerHandler extends SimpleChannelInboundHandler { + private Http2Headers requestHeaders; + private final List bodyChunks = new ArrayList<>(); + + @Override + protected void channelRead0(ChannelHandlerContext ctx, Object msg) { + if (msg instanceof Http2HeadersFrame) { + Http2HeadersFrame headersFrame = (Http2HeadersFrame) msg; + this.requestHeaders = headersFrame.headers(); + if (headersFrame.isEndStream()) { + routeRequest(ctx, Unpooled.EMPTY_BUFFER); + } + } else if (msg instanceof Http2DataFrame) { + Http2DataFrame dataFrame = (Http2DataFrame) msg; + bodyChunks.add(dataFrame.content().retain()); + if (dataFrame.isEndStream()) { + int totalBytes = bodyChunks.stream().mapToInt(ByteBuf::readableBytes).sum(); + ByteBuf combined = ctx.alloc().buffer(totalBytes); + bodyChunks.forEach(chunk -> { + combined.writeBytes(chunk); + chunk.release(); + }); + bodyChunks.clear(); + routeRequest(ctx, combined); + } + } + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + releaseBodyChunks(); + super.channelInactive(ctx); + } + + private void releaseBodyChunks() { + for (ByteBuf chunk : bodyChunks) { + if (chunk.refCnt() > 0) { + chunk.release(); + } + } + bodyChunks.clear(); + } + + private void routeRequest(ChannelHandlerContext ctx, ByteBuf body) { + String path = requestHeaders.path() != null ? requestHeaders.path().toString() : "/"; + String method = requestHeaders.method() != null ? requestHeaders.method().toString() : "GET"; + + // Strip query string for routing + String queryString = null; + int qIdx = path.indexOf('?'); + String routePath = path; + if (qIdx >= 0) { + queryString = path.substring(qIdx + 1); + routePath = path.substring(0, qIdx); + } + + if (routePath.equals("/ok")) { + ReferenceCountUtil.safeRelease(body); + sendSimpleResponse(ctx, "200", Unpooled.EMPTY_BUFFER, null); + } else if (routePath.startsWith("/status/")) { + String statusCode = routePath.substring("/status/".length()); + ReferenceCountUtil.safeRelease(body); + sendSimpleResponse(ctx, statusCode, Unpooled.EMPTY_BUFFER, null); + } else if (routePath.startsWith("/delay/")) { + long millis = Long.parseLong(routePath.substring("/delay/".length())); + ReferenceCountUtil.safeRelease(body); + ctx.executor().schedule(() -> { + if (ctx.channel().isActive()) { + sendSimpleResponse(ctx, "200", Unpooled.EMPTY_BUFFER, null); + } + }, millis, TimeUnit.MILLISECONDS); + } else if (routePath.startsWith("/redirect/")) { + int count = Integer.parseInt(routePath.substring("/redirect/".length())); + ReferenceCountUtil.safeRelease(body); + Http2Headers responseHeaders = new DefaultHttp2Headers().status("302"); + if (count > 0) { + responseHeaders.add("location", "/redirect/" + (count - 1)); + } else { + responseHeaders.status("200"); + } + ctx.write(new DefaultHttp2HeadersFrame(responseHeaders, true)); + ctx.flush(); + } else if (routePath.equals("/head")) { + ReferenceCountUtil.safeRelease(body); + Http2Headers responseHeaders = new DefaultHttp2Headers() + .status("200") + .add(HttpHeaderNames.CONTENT_LENGTH, "100"); + if ("HEAD".equalsIgnoreCase(method)) { + ctx.write(new DefaultHttp2HeadersFrame(responseHeaders, true)); + ctx.flush(); + } else { + sendSimpleResponse(ctx, "200", Unpooled.EMPTY_BUFFER, null); + } + } else if (routePath.equals("/options")) { + ReferenceCountUtil.safeRelease(body); + Http2Headers responseHeaders = new DefaultHttp2Headers() + .status("200") + .add("allow", "GET,HEAD,POST,OPTIONS,TRACE"); + ctx.write(new DefaultHttp2HeadersFrame(responseHeaders, true)); + ctx.flush(); + } else if (routePath.equals("/cookies")) { + ReferenceCountUtil.safeRelease(body); + Http2Headers responseHeaders = new DefaultHttp2Headers().status("200"); + CharSequence cookieHeader = requestHeaders.get("cookie"); + if (cookieHeader != null) { + String[] cookies = cookieHeader.toString().split(";\\s*"); + for (String cookie : cookies) { + responseHeaders.add("set-cookie", cookie.trim()); + } + } + ctx.write(new DefaultHttp2HeadersFrame(responseHeaders, true)); + ctx.flush(); + } else if (routePath.equals("/reset")) { + ReferenceCountUtil.safeRelease(body); + ctx.writeAndFlush(new DefaultHttp2ResetFrame(Http2Error.INTERNAL_ERROR)); + } else { + // Default: echo handler — takes ownership of body via writeResponse + sendEchoResponse(ctx, body, path, routePath, queryString, method); + } + } + + private void sendEchoResponse(ChannelHandlerContext ctx, ByteBuf body, String fullPath, + String routePath, String queryString, String method) { + Http2Headers responseHeaders = new DefaultHttp2Headers().status("200"); + + // Echo Content-Type + if (requestHeaders.get(CONTENT_TYPE) != null) { + responseHeaders.add(CONTENT_TYPE, requestHeaders.get(CONTENT_TYPE)); + } + + // Echo path info + responseHeaders.add("x-pathinfo", routePath); + + // Echo query string + if (queryString != null) { + responseHeaders.add("x-querystring", queryString); + } + + // Echo request headers as X-{name} + for (Map.Entry entry : requestHeaders) { + String name = entry.getKey().toString(); + // Skip pseudo-headers + if (!name.startsWith(":")) { + responseHeaders.add("x-" + name, entry.getValue()); + } + } + + // Handle OPTIONS + if ("OPTIONS".equalsIgnoreCase(method)) { + responseHeaders.add("allow", "GET,HEAD,POST,OPTIONS,TRACE"); + } + + // Parse form parameters from body if content-type is form-urlencoded + CharSequence contentType = requestHeaders.get(CONTENT_TYPE); + if (contentType != null && contentType.toString().contains("application/x-www-form-urlencoded") + && body.isReadable()) { + String bodyStr = body.toString(UTF_8); + QueryStringDecoder decoder = new QueryStringDecoder("?" + bodyStr); + for (Map.Entry> entry : decoder.parameters().entrySet()) { + String value = entry.getValue().get(0); + responseHeaders.add("x-" + entry.getKey(), + URLEncoder.encode(value, UTF_8)); + } + } + + // Handle cookies + CharSequence cookieHeader = requestHeaders.get("cookie"); + if (cookieHeader != null) { + String[] cookies = cookieHeader.toString().split(";\\s*"); + for (String cookie : cookies) { + responseHeaders.add("set-cookie", cookie.trim()); + } + } + + responseHeaders.add(HttpHeaderNames.CONTENT_LENGTH, String.valueOf(body.readableBytes())); + writeResponse(ctx, responseHeaders, body); + } + + private void sendSimpleResponse(ChannelHandlerContext ctx, String status, ByteBuf body, + Map extraHeaders) { + Http2Headers responseHeaders = new DefaultHttp2Headers() + .status(status) + .add(HttpHeaderNames.CONTENT_LENGTH, String.valueOf(body.readableBytes())); + if (extraHeaders != null) { + extraHeaders.forEach(responseHeaders::add); + } + writeResponse(ctx, responseHeaders, body); + } + + private void writeResponse(ChannelHandlerContext ctx, Http2Headers responseHeaders, ByteBuf body) { + boolean hasBody = body.isReadable(); + ctx.write(new DefaultHttp2HeadersFrame(responseHeaders, !hasBody)); + if (hasBody) { + ctx.writeAndFlush(new DefaultHttp2DataFrame(body, true)).addListener(f -> { + if (!f.isSuccess() && body.refCnt() > 0) { + body.release(); + } + }); + } else { + ctx.flush(); + ReferenceCountUtil.safeRelease(body); + } + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { + ctx.close(); + } + } + + @BeforeEach + public void startServer() throws Exception { + SelfSignedCertificate ssc = new SelfSignedCertificate(); + + serverSslCtx = SslContextBuilder.forServer(ssc.certificate(), ssc.privateKey()) + .applicationProtocolConfig(new ApplicationProtocolConfig( + ApplicationProtocolConfig.Protocol.ALPN, + ApplicationProtocolConfig.SelectorFailureBehavior.NO_ADVERTISE, + ApplicationProtocolConfig.SelectedListenerFailureBehavior.ACCEPT, + ApplicationProtocolNames.HTTP_2, + ApplicationProtocolNames.HTTP_1_1)) + .build(); + + serverGroup = new NioEventLoopGroup(1); + serverChildChannels = new DefaultChannelGroup("http2-test-server", GlobalEventExecutor.INSTANCE); + + ServerBootstrap b = new ServerBootstrap() + .group(serverGroup) + .channel(NioServerSocketChannel.class) + .childHandler(new ChannelInitializer() { + @Override + protected void initChannel(Channel ch) { + serverChildChannels.add(ch); + ch.pipeline() + .addLast("ssl", serverSslCtx.newHandler(ch.alloc())) + .addLast(Http2FrameCodecBuilder.forServer().build()) + .addLast(new Http2MultiplexHandler(new ChannelInitializer() { + @Override + protected void initChannel(Http2StreamChannel streamCh) { + streamCh.pipeline().addLast(new Http2TestServerHandler()); + } + })); + } + }); + + serverChannel = b.bind(0).sync().channel(); + serverPort = ((java.net.InetSocketAddress) serverChannel.localAddress()).getPort(); + } + + @AfterEach + public void stopServer() throws InterruptedException { + if (serverChildChannels != null) { + serverChildChannels.close().sync(); + } + if (serverChannel != null) { + serverChannel.close().sync(); + } + if (serverGroup != null) { + serverGroup.shutdownGracefully(0, 100, TimeUnit.MILLISECONDS).sync(); + } + ReferenceCountUtil.release(serverSslCtx); + } + + private String httpsUrl(String path) { + return "https://localhost:" + serverPort + path; + } + + /** + * Creates an AHC client configured to trust self-signed certs (for testing) with HTTP/2 enabled. + */ + private AsyncHttpClient http2Client() { + return asyncHttpClient(config() + .setUseInsecureTrustManager(true) + .setHttp2Enabled(true)); + } + + /** + * Creates an AHC client with HTTP/2 disabled (forced HTTP/1.1 fallback). + */ + private AsyncHttpClient http1Client() { + return asyncHttpClient(config() + .setUseInsecureTrustManager(true) + .setHttp2Enabled(false)); + } + + /** + * Creates an AHC client with custom config + trust manager + HTTP/2. + */ + private AsyncHttpClient http2ClientWithConfig(Consumer customizer) { + DefaultAsyncHttpClientConfig.Builder builder = config() + .setUseInsecureTrustManager(true) + .setHttp2Enabled(true); + customizer.accept(builder); + return asyncHttpClient(builder); + } + + /** + * Creates an AHC client with a specific request timeout. + */ + private AsyncHttpClient http2ClientWithTimeout(int requestTimeoutMs) { + return http2ClientWithConfig(b -> b.setRequestTimeout(Duration.ofMillis(requestTimeoutMs))); + } + + /** + * Creates an AHC client configured for redirect tests. + */ + private AsyncHttpClient http2ClientWithRedirects(int maxRedirects) { + return http2ClientWithConfig(b -> b.setMaxRedirects(maxRedirects).setFollowRedirect(true)); + } + + // ------------------------------------------------------------------------- + // Existing test cases + // ------------------------------------------------------------------------- + + @Test + public void simpleGetOverHttp2() throws Exception { + try (AsyncHttpClient client = http2Client()) { + Response response = client.prepareGet(httpsUrl("/hello")) + .execute() + .get(30, SECONDS); + + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + } + } + + @Test + public void postStringBodyOverHttp2() throws Exception { + String body = "Hello HTTP/2 world!"; + try (AsyncHttpClient client = http2Client()) { + Response response = client.preparePost(httpsUrl("/echo")) + .setBody(body) + .setHeader(CONTENT_TYPE, "text/plain") + .execute() + .get(30, SECONDS); + + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + assertEquals(body, response.getResponseBody()); + } + } + + @Test + public void postByteArrayBodyOverHttp2() throws Exception { + byte[] body = "Binary data over HTTP/2".getBytes(StandardCharsets.UTF_8); + try (AsyncHttpClient client = http2Client()) { + Response response = client.preparePost(httpsUrl("/echo")) + .setBody(body) + .setHeader(CONTENT_TYPE, "application/octet-stream") + .execute() + .get(30, SECONDS); + + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + assertArrayEquals(body, response.getResponseBodyAsBytes()); + } + } + + @Test + public void largeBodyOverHttp2() throws Exception { + // 64KB body to test DATA frame handling + byte[] body = new byte[64 * 1024]; + for (int i = 0; i < body.length; i++) { + body[i] = (byte) (i % 256); + } + try (AsyncHttpClient client = http2Client()) { + Response response = client.preparePost(httpsUrl("/echo")) + .setBody(body) + .setHeader(CONTENT_TYPE, "application/octet-stream") + .execute() + .get(30, SECONDS); + + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + assertArrayEquals(body, response.getResponseBodyAsBytes()); + } + } + + @Test + public void multipleSequentialRequestsOverHttp2() throws Exception { + try (AsyncHttpClient client = http2Client()) { + for (int i = 0; i < 5; i++) { + String body = "Request " + i; + Response response = client.preparePost(httpsUrl("/echo")) + .setBody(body) + .setHeader(CONTENT_TYPE, "text/plain") + .execute() + .get(30, SECONDS); + + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + assertEquals(body, response.getResponseBody()); + } + } + } + + @Test + public void multipleConcurrentRequestsOverHttp2() throws Exception { + int numRequests = 10; + CountDownLatch latch = new CountDownLatch(numRequests); + AtomicInteger successCount = new AtomicInteger(0); + AtomicReference error = new AtomicReference<>(); + + try (AsyncHttpClient client = http2Client()) { + List> futures = new ArrayList<>(); + for (int i = 0; i < numRequests; i++) { + String body = "Concurrent request " + i; + CompletableFuture future = client.preparePost(httpsUrl("/echo")) + .setBody(body) + .setHeader(CONTENT_TYPE, "text/plain") + .execute() + .toCompletableFuture() + .whenComplete((r, t) -> { + if (t != null) { + error.compareAndSet(null, t); + } else { + successCount.incrementAndGet(); + } + latch.countDown(); + }); + futures.add(future); + } + + assertTrue(latch.await(30, SECONDS), "Timed out waiting for concurrent requests"); + assertNull(error.get(), "Unexpected error: " + error.get()); + assertEquals(numRequests, successCount.get()); + } + } + + @Test + public void http2HeadersContainPseudoHeaders() throws Exception { + try (AsyncHttpClient client = http2Client()) { + Response response = client.prepareGet(httpsUrl("/headers-check")) + .addHeader("X-Custom-Header", "test-value") + .execute() + .get(30, SECONDS); + + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + } + } + + @Test + public void http2ResponseReportsCorrectProtocol() throws Exception { + try (AsyncHttpClient client = http2Client()) { + Response response = client.prepareGet(httpsUrl("/hello")) + .execute() + .get(30, SECONDS); + + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + assertEquals(HttpProtocol.HTTP_2, response.getProtocol(), + "Response should report HTTP/2 protocol"); + } + } + + @Test + public void http2DisabledFallsBackToHttp11() throws Exception { + try (AsyncHttpClient client = http1Client()) { + assertNotNull(client); + } + } + + @Test + public void http2IsEnabledByDefault() { + AsyncHttpClientConfig defaultConfig = config().build(); + assertTrue(defaultConfig.isHttp2Enabled(), + "HTTP/2 should be enabled by default"); + } + + @Test + public void http2CanBeDisabledViaConfig() { + AsyncHttpClientConfig configWithHttp2Disabled = config() + .setHttp2Enabled(false) + .build(); + assertFalse(configWithHttp2Disabled.isHttp2Enabled(), + "HTTP/2 should be disabled when setHttp2Enabled(false) is called"); + } + + // ------------------------------------------------------------------------- + // Basic request/response tests (mirrored from BasicHttpTest) + // ------------------------------------------------------------------------- + + @Test + public void getRootUrlOverHttp2() throws Exception { + try (AsyncHttpClient client = http2Client()) { + Response response = client.prepareGet(httpsUrl("/ok")) + .execute() + .get(30, SECONDS); + + assertEquals(200, response.getStatusCode()); + } + } + + @Test + public void getResponseBodyOverHttp2() throws Exception { + String body = "Hello World"; + try (AsyncHttpClient client = http2Client()) { + Response response = client.preparePost(httpsUrl("/echo")) + .setBody(body) + .setHeader(CONTENT_TYPE, "text/plain") + .execute() + .get(30, SECONDS); + + assertEquals(200, response.getStatusCode()); + assertEquals(body, response.getResponseBody()); + } + } + + @Test + public void getEmptyBodyOverHttp2() throws Exception { + try (AsyncHttpClient client = http2Client()) { + Response response = client.prepareGet(httpsUrl("/ok")) + .execute() + .get(30, SECONDS); + + assertEquals(200, response.getStatusCode()); + assertTrue(response.getResponseBody().isEmpty()); + } + } + + @Test + public void getEmptyBodyNotifiesHandlerOverHttp2() throws Exception { + try (AsyncHttpClient client = http2Client()) { + final AtomicBoolean handlerWasNotified = new AtomicBoolean(); + + client.prepareGet(httpsUrl("/ok")).execute(new AsyncCompletionHandlerAdapter() { + @Override + public Response onCompleted(Response response) { + assertEquals(200, response.getStatusCode()); + handlerWasNotified.set(true); + return response; + } + }).get(30, SECONDS); + + assertTrue(handlerWasNotified.get()); + } + } + + @Test + public void headHasEmptyBodyOverHttp2() throws Exception { + try (AsyncHttpClient client = http2Client()) { + Response response = client.prepareHead(httpsUrl("/head")) + .execute() + .get(30, SECONDS); + + assertEquals(200, response.getStatusCode()); + assertTrue(response.getResponseBody().isEmpty()); + } + } + + @Test + public void defaultRequestBodyEncodingIsUtf8OverHttp2() throws Exception { + try (AsyncHttpClient client = http2Client()) { + Response response = client.preparePost(httpsUrl("/echo")) + .setBody("\u017D\u017D\u017D\u017D\u017D\u017D") + .execute() + .get(30, SECONDS); + + assertArrayEquals(response.getResponseBodyAsBytes(), + "\u017D\u017D\u017D\u017D\u017D\u017D".getBytes(UTF_8)); + } + } + + // ------------------------------------------------------------------------- + // Path and query string tests + // ------------------------------------------------------------------------- + + @Test + public void getUrlWithPathWithoutQueryOverHttp2() throws Exception { + try (AsyncHttpClient client = http2Client()) { + Response response = client.prepareGet(httpsUrl("/foo/bar")) + .execute() + .get(30, SECONDS); + + assertEquals(200, response.getStatusCode()); + assertEquals("/foo/bar", response.getHeader("X-PathInfo")); + } + } + + @Test + public void getUrlWithPathWithQueryOverHttp2() throws Exception { + try (AsyncHttpClient client = http2Client()) { + Response response = client.prepareGet(httpsUrl("/foo/bar?q=+%20x")) + .execute() + .get(30, SECONDS); + + assertEquals(200, response.getStatusCode()); + assertEquals("/foo/bar", response.getHeader("X-PathInfo")); + assertNotNull(response.getHeader("X-QueryString")); + } + } + + @Test + public void getUrlWithPathWithQueryParamsOverHttp2() throws Exception { + try (AsyncHttpClient client = http2Client()) { + Response response = client.prepareGet(httpsUrl("/foo/bar")) + .addQueryParam("q", "a b") + .execute() + .get(30, SECONDS); + + assertEquals(200, response.getStatusCode()); + assertNotNull(response.getHeader("X-QueryString")); + } + } + + @Test + public void getProperPathAndQueryStringOverHttp2() throws Exception { + try (AsyncHttpClient client = http2Client()) { + Response response = client.prepareGet(httpsUrl("/foo/bar?foo=bar")) + .execute() + .get(30, SECONDS); + + assertEquals(200, response.getStatusCode()); + assertNotNull(response.getHeader("X-PathInfo")); + assertNotNull(response.getHeader("X-QueryString")); + } + } + + // ------------------------------------------------------------------------- + // Headers and cookies tests + // ------------------------------------------------------------------------- + + @Test + public void getWithHeadersOverHttp2() throws Exception { + try (AsyncHttpClient client = http2Client()) { + Response response = client.prepareGet(httpsUrl("/echo")) + .addHeader("Test1", "Test1") + .addHeader("Test2", "Test2") + .addHeader("Test3", "Test3") + .addHeader("Test4", "Test4") + .execute() + .get(30, SECONDS); + + assertEquals(200, response.getStatusCode()); + for (int i = 1; i < 5; i++) { + assertEquals("Test" + i, response.getHeader("X-test" + i)); + } + } + } + + @Test + public void postWithHeadersAndFormParamsOverHttp2() throws Exception { + try (AsyncHttpClient client = http2Client()) { + Map> m = new HashMap<>(); + for (int i = 0; i < 5; i++) { + m.put("param_" + i, Collections.singletonList("value_" + i)); + } + + Response response = client.preparePost(httpsUrl("/echo")) + .setHeader(CONTENT_TYPE, HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED) + .setFormParams(m) + .execute() + .get(30, SECONDS); + + assertEquals(200, response.getStatusCode()); + for (int i = 0; i < 5; i++) { + assertEquals("value_" + i, + URLDecoder.decode(response.getHeader("X-param_" + i), UTF_8)); + } + } + } + + @Test + public void postChineseCharOverHttp2() throws Exception { + try (AsyncHttpClient client = http2Client()) { + String chineseChar = "\u662F"; + + Map> m = new HashMap<>(); + m.put("param", Collections.singletonList(chineseChar)); + + Response response = client.preparePost(httpsUrl("/echo")) + .setHeader(CONTENT_TYPE, HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED) + .setFormParams(m) + .execute() + .get(30, SECONDS); + + assertEquals(200, response.getStatusCode()); + String value = URLDecoder.decode(response.getHeader("X-param"), UTF_8); + assertEquals(chineseChar, value); + } + } + + @Test + public void getWithCookiesOverHttp2() throws Exception { + try (AsyncHttpClient client = http2Client()) { + Response response = client.prepareGet(httpsUrl("/cookies")) + .addHeader("cookie", "foo=value") + .execute() + .get(30, SECONDS); + + assertEquals(200, response.getStatusCode()); + String setCookie = response.getHeader("set-cookie"); + assertNotNull(setCookie); + assertTrue(setCookie.contains("foo=value")); + } + } + + @Test + public void postFormParametersAsBodyStringOverHttp2() throws Exception { + try (AsyncHttpClient client = http2Client()) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 5; i++) { + sb.append("param_").append(i).append("=value_").append(i).append('&'); + } + sb.setLength(sb.length() - 1); + + Response response = client.preparePost(httpsUrl("/echo")) + .setHeader(CONTENT_TYPE, HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED) + .setBody(sb.toString()) + .execute() + .get(30, SECONDS); + + assertEquals(200, response.getStatusCode()); + for (int i = 0; i < 5; i++) { + assertEquals("value_" + i, + URLDecoder.decode(response.getHeader("X-param_" + i), UTF_8)); + } + } + } + + // ------------------------------------------------------------------------- + // Timeout and cancellation tests + // ------------------------------------------------------------------------- + + @Test + public void cancelledFutureThrowsCancellationExceptionOverHttp2() throws Exception { + try (AsyncHttpClient client = http2Client()) { + Future future = client.prepareGet(httpsUrl("/delay/5000")) + .execute(new AsyncCompletionHandlerAdapter() { + @Override + public void onThrowable(Throwable t) { + } + }); + future.cancel(true); + assertThrows(CancellationException.class, () -> future.get(30, SECONDS)); + } + } + + @Test + public void futureTimeOutThrowsTimeoutExceptionOverHttp2() throws Exception { + try (AsyncHttpClient client = http2Client()) { + Future future = client.prepareGet(httpsUrl("/delay/5000")) + .execute(new AsyncCompletionHandlerAdapter() { + @Override + public void onThrowable(Throwable t) { + } + }); + + assertThrows(TimeoutException.class, () -> future.get(2, SECONDS)); + } + } + + @Test + public void configTimeoutNotifiesOnThrowableAndFutureOverHttp2() throws Exception { + try (AsyncHttpClient client = http2ClientWithTimeout(1000)) { + final AtomicBoolean onCompletedWasNotified = new AtomicBoolean(); + final AtomicBoolean onThrowableWasNotifiedWithTimeoutException = new AtomicBoolean(); + final CountDownLatch latch = new CountDownLatch(1); + + Future whenResponse = client.prepareGet(httpsUrl("/delay/5000")) + .execute(new AsyncCompletionHandlerAdapter() { + @Override + public Response onCompleted(Response response) { + onCompletedWasNotified.set(true); + latch.countDown(); + return response; + } + + @Override + public void onThrowable(Throwable t) { + onThrowableWasNotifiedWithTimeoutException.set(t instanceof TimeoutException); + latch.countDown(); + } + }); + + if (!latch.await(30, SECONDS)) { + fail("Timed out"); + } + + assertFalse(onCompletedWasNotified.get()); + assertTrue(onThrowableWasNotifiedWithTimeoutException.get()); + + assertThrows(ExecutionException.class, () -> whenResponse.get(30, SECONDS)); + } + } + + @Test + public void configRequestTimeoutHappensInDueTimeOverHttp2() throws Exception { + try (AsyncHttpClient client = http2ClientWithTimeout(1000)) { + long start = unpreciseMillisTime(); + try { + client.prepareGet(httpsUrl("/delay/2000")).execute().get(); + fail("Should have thrown"); + } catch (ExecutionException ex) { + final long elapsedTime = unpreciseMillisTime() - start; + assertTrue(elapsedTime >= 1_000 && elapsedTime <= 1_500, + "Elapsed time was " + elapsedTime + "ms"); + } + } + } + + @Test + public void cancellingFutureNotifiesOnThrowableWithCancellationExceptionOverHttp2() throws Exception { + try (AsyncHttpClient client = http2Client()) { + CountDownLatch latch = new CountDownLatch(1); + + Future future = client.preparePost(httpsUrl("/delay/2000")) + .setBody("Body") + .execute(new AsyncCompletionHandlerAdapter() { + @Override + public void onThrowable(Throwable t) { + if (t instanceof CancellationException) { + latch.countDown(); + } + } + }); + + future.cancel(true); + if (!latch.await(30, SECONDS)) { + fail("Timed out"); + } + } + } + + // ------------------------------------------------------------------------- + // Handler exception notification tests + // ------------------------------------------------------------------------- + + @Test + public void exceptionInOnCompletedGetNotifiedToOnThrowableOverHttp2() throws Exception { + try (AsyncHttpClient client = http2Client()) { + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference message = new AtomicReference<>(); + + client.prepareGet(httpsUrl("/ok")).execute(new AsyncCompletionHandlerAdapter() { + @Override + public Response onCompleted(Response response) { + throw unknownStackTrace(new IllegalStateException("FOO"), + BasicHttp2Test.class, "exceptionInOnCompletedGetNotifiedToOnThrowableOverHttp2"); + } + + @Override + public void onThrowable(Throwable t) { + message.set(t.getMessage()); + latch.countDown(); + } + }); + + if (!latch.await(30, SECONDS)) { + fail("Timed out"); + } + + assertEquals("FOO", message.get()); + } + } + + @Test + public void exceptionInOnCompletedGetNotifiedToFutureOverHttp2() throws Exception { + try (AsyncHttpClient client = http2Client()) { + Future whenResponse = client.prepareGet(httpsUrl("/ok")) + .execute(new AsyncCompletionHandlerAdapter() { + @Override + public Response onCompleted(Response response) { + throw unknownStackTrace(new IllegalStateException("FOO"), + BasicHttp2Test.class, "exceptionInOnCompletedGetNotifiedToFutureOverHttp2"); + } + + @Override + public void onThrowable(Throwable t) { + } + }); + + try { + whenResponse.get(30, SECONDS); + fail("Should have thrown"); + } catch (ExecutionException e) { + assertInstanceOf(IllegalStateException.class, e.getCause()); + } + } + } + + // ------------------------------------------------------------------------- + // Redirects and methods tests + // ------------------------------------------------------------------------- + + @Test + public void reachingMaxRedirectThrowsMaxRedirectExceptionOverHttp2() throws Exception { + try (AsyncHttpClient client = http2ClientWithRedirects(1)) { + try { + client.prepareGet(httpsUrl("/redirect/3")) + .execute(new AsyncCompletionHandlerAdapter() { + @Override + public Response onCompleted(Response response) { + fail("Should not be here"); + return response; + } + + @Override + public void onThrowable(Throwable t) { + } + }).get(30, SECONDS); + fail("Should have thrown"); + } catch (ExecutionException e) { + assertInstanceOf(org.asynchttpclient.handler.MaxRedirectException.class, e.getCause()); + } + } + } + + @Test + public void optionsIsSupportedOverHttp2() throws Exception { + try (AsyncHttpClient client = http2Client()) { + Response response = client.prepareOptions(httpsUrl("/options")) + .execute() + .get(30, SECONDS); + + assertEquals(200, response.getStatusCode()); + assertEquals("GET,HEAD,POST,OPTIONS,TRACE", response.getHeader("allow")); + } + } + + // ------------------------------------------------------------------------- + // Connection events tests + // ------------------------------------------------------------------------- + + @Test + public void newConnectionEventsAreFiredOverHttp2() throws Exception { + try (AsyncHttpClient client = http2Client()) { + EventCollectingHandler handler = new EventCollectingHandler(); + client.prepareGet(httpsUrl("/ok")).execute(handler).get(30, SECONDS); + handler.waitForCompletion(30, SECONDS); + + Object[] expectedEvents = { + CONNECTION_POOL_EVENT, + HOSTNAME_RESOLUTION_EVENT, + HOSTNAME_RESOLUTION_SUCCESS_EVENT, + CONNECTION_OPEN_EVENT, + CONNECTION_SUCCESS_EVENT, + TLS_HANDSHAKE_EVENT, + TLS_HANDSHAKE_SUCCESS_EVENT, + STATUS_RECEIVED_EVENT, + HEADERS_RECEIVED_EVENT, + CONNECTION_OFFER_EVENT, + COMPLETED_EVENT}; + + assertArrayEquals(expectedEvents, handler.firedEvents.toArray(), + "Got " + Arrays.toString(handler.firedEvents.toArray())); + } + } + + // ------------------------------------------------------------------------- + // HTTP/2-specific tests + // ------------------------------------------------------------------------- + + @Test + public void http2ErrorStatusCodesAreReported() throws Exception { + try (AsyncHttpClient client = http2Client()) { + Response response404 = client.prepareGet(httpsUrl("/status/404")) + .execute() + .get(30, SECONDS); + assertEquals(404, response404.getStatusCode()); + + Response response500 = client.prepareGet(httpsUrl("/status/500")) + .execute() + .get(30, SECONDS); + assertEquals(500, response500.getStatusCode()); + } + } + + @Test + public void http2StreamResetIsHandledGracefully() throws Exception { + try (AsyncHttpClient client = http2ClientWithTimeout(5000)) { + try { + client.prepareGet(httpsUrl("/reset")) + .execute() + .get(10, SECONDS); + fail("Should have thrown"); + } catch (ExecutionException e) { + assertNotNull(e.getCause()); + } + } + } + + @Test + public void postByteBodyOverHttp2() throws Exception { + byte[] bodyBytes = "Hello from byte array body".getBytes(UTF_8); + try (AsyncHttpClient client = http2Client()) { + Response response = client.preparePost(httpsUrl("/echo")) + .setHeader(CONTENT_TYPE, "application/octet-stream") + .setBody(bodyBytes) + .execute() + .get(30, SECONDS); + + assertEquals(200, response.getStatusCode()); + assertArrayEquals(bodyBytes, response.getResponseBodyAsBytes()); + } + } +} diff --git a/client/src/test/java/org/asynchttpclient/LargeResponseTest.java b/client/src/test/java/org/asynchttpclient/LargeResponseTest.java index df0558f566..5651ebb84b 100644 --- a/client/src/test/java/org/asynchttpclient/LargeResponseTest.java +++ b/client/src/test/java/org/asynchttpclient/LargeResponseTest.java @@ -46,7 +46,7 @@ public class LargeResponseTest { private static final int textSize = 4 * 1024; private static final byte[] textBytes = "z".repeat(textSize).getBytes(StandardCharsets.UTF_8); - private static final long responseSize = ((long)textSize) * (1_500_000L); + private static final long responseSize = ((long)textSize) * (100_000L); private static HttpServer HTTP_SERVER; diff --git a/client/src/test/java/org/asynchttpclient/channel/MaxTotalConnectionTest.java b/client/src/test/java/org/asynchttpclient/channel/MaxTotalConnectionTest.java index 6094f5bdb9..345ce9818f 100644 --- a/client/src/test/java/org/asynchttpclient/channel/MaxTotalConnectionTest.java +++ b/client/src/test/java/org/asynchttpclient/channel/MaxTotalConnectionTest.java @@ -40,7 +40,7 @@ public class MaxTotalConnectionTest extends AbstractBasicTest { @RepeatedIfExceptionsTest(repeats = 5) public void testMaxTotalConnectionsExceedingException() throws IOException { - String[] urls = {"https://google.com", "https://github.com"}; + String[] urls = {getTargetUrl(), String.format("http://localhost:%d/foo/test", port2)}; AsyncHttpClientConfig config = config() .setConnectTimeout(Duration.ofSeconds(1)) @@ -76,7 +76,7 @@ public void testMaxTotalConnectionsExceedingException() throws IOException { @RepeatedIfExceptionsTest(repeats = 5) public void testMaxTotalConnections() throws Exception { - String[] urls = {"https://www.google.com", "https://www.youtube.com"}; + String[] urls = {getTargetUrl(), String.format("http://localhost:%d/foo/test", port2)}; final CountDownLatch latch = new CountDownLatch(2); final AtomicReference ex = new AtomicReference<>(); diff --git a/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyTestcontainersIntegrationTest.java b/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyTestcontainersIntegrationTest.java index e915e86663..40b0f6ee3e 100644 --- a/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyTestcontainersIntegrationTest.java +++ b/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyTestcontainersIntegrationTest.java @@ -144,6 +144,8 @@ public void testHttpProxyToHttpsTarget() throws Exception { .setProxyType(ProxyType.HTTP) .build()) .setUseInsecureTrustManager(true) + // HTTP/2 ALPN upgrade after proxy CONNECT tunnel is not yet supported + .setHttp2Enabled(false) .setConnectTimeout(Duration.ofMillis(10000)) .setRequestTimeout(Duration.ofMillis(30000)) .build(); @@ -165,6 +167,8 @@ public void testHttpsProxyToHttpsTarget() throws Exception { .setProxyType(ProxyType.HTTPS) .build()) .setUseInsecureTrustManager(true) + // HTTP/2 ALPN upgrade after proxy CONNECT tunnel is not yet supported + .setHttp2Enabled(false) .setConnectTimeout(Duration.ofMillis(10000)) .setRequestTimeout(Duration.ofMillis(30000)) .build(); diff --git a/pom.xml b/pom.xml index f7d4807e9d..a089bd8723 100644 --- a/pom.xml +++ b/pom.xml @@ -130,6 +130,12 @@ ${netty.version} + + io.netty + netty-codec-http2 + ${netty.version} + + io.netty netty-codec