BasicHttp2Test.java

/*
 *    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.pkitesting.CertificateBuilder;
import io.netty.pkitesting.X509Bundle;
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.
 * <p>
 * The embedded server uses {@link Http2FrameCodecBuilder} and {@link Http2MultiplexHandler} on
 * the server side, and tests verify that the client correctly:
 * <ul>
 *   <li>Negotiates HTTP/2 via ALPN</li>
 *   <li>Sends requests as HTTP/2 frames ({@link Http2HeadersFrame} + {@link Http2DataFrame})</li>
 *   <li>Receives responses and delivers them via the normal {@link AsyncHandler} callback sequence</li>
 *   <li>Correctly multiplexes concurrent requests over a single connection</li>
 *   <li>Falls back to HTTP/1.1 when HTTP/2 is disabled</li>
 * </ul>
 */
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<Object> {
        private Http2Headers requestHeaders;
        private final List<ByteBuf> 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<CharSequence, CharSequence> 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<String, List<String>> 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<String, String> 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 {
        X509Bundle bundle = new CertificateBuilder()
                .subject("CN=localhost")
                .setIsCertificateAuthority(true)
                .buildSelfSigned();

        serverSslCtx = SslContextBuilder.forServer(bundle.toKeyManagerFactory())
                .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<Channel>() {
                    @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<Http2StreamChannel>() {
                                    @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<DefaultAsyncHttpClientConfig.Builder> 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<Throwable> error = new AtomicReference<>();

        try (AsyncHttpClient client = http2Client()) {
            List<CompletableFuture<Response>> futures = new ArrayList<>();
            for (int i = 0; i < numRequests; i++) {
                String body = "Concurrent request " + i;
                CompletableFuture<Response> 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<String, List<String>> 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<String, List<String>> 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<Response> 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<Response> 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<Response> 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<Response> 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<String> 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<Response> 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,
                    REQUEST_SEND_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());
        }
    }

    // -------------------------------------------------------------------------
    // HTTP/2 multiplexing and connection management tests
    // -------------------------------------------------------------------------

    @Test
    public void http2MultiplexesConcurrentRequestsOnSingleConnection() throws Exception {
        try (AsyncHttpClient client = http2ClientWithConfig(b -> b.setMaxConnectionsPerHost(1))) {
            int concurrentRequests = 10;
            CountDownLatch latch = new CountDownLatch(concurrentRequests);
            AtomicInteger successCount = new AtomicInteger(0);
            AtomicReference<Throwable> firstError = new AtomicReference<>();

            // Fire off concurrent requests ��� with maxConnectionsPerHost=1 and HTTP/1.1,
            // these would block waiting for the single connection. With HTTP/2 multiplexing,
            // they should all complete on the same connection concurrently.
            for (int i = 0; i < concurrentRequests; i++) {
                final int idx = i;
                client.prepareGet(httpsUrl("/delay/100"))
                        .execute(new AsyncCompletionHandlerBase() {
                            @Override
                            public Response onCompleted(Response response) throws Exception {
                                if (response.getStatusCode() == 200) {
                                    successCount.incrementAndGet();
                                }
                                latch.countDown();
                                return response;
                            }

                            @Override
                            public void onThrowable(Throwable t) {
                                firstError.compareAndSet(null, t);
                                latch.countDown();
                            }
                        });
            }

            assertTrue(latch.await(30, SECONDS), "All requests should complete within 30s");
            assertNull(firstError.get(), "No errors expected, got: " + firstError.get());
            assertEquals(concurrentRequests, successCount.get(),
                    "All concurrent requests should succeed via HTTP/2 multiplexing");
        }
    }

    @Test
    public void http2ConnectionIsReusedAcrossSequentialRequests() throws Exception {
        try (AsyncHttpClient client = http2Client()) {
            // First request ��� establishes the HTTP/2 connection
            Response response1 = client.prepareGet(httpsUrl("/ok")).execute().get(30, SECONDS);
            assertEquals(200, response1.getStatusCode());

            // Second request ��� should reuse the same HTTP/2 connection from the registry
            EventCollectingHandler handler = new EventCollectingHandler();
            Response response2 = client.prepareGet(httpsUrl("/ok")).execute(handler).get(30, SECONDS);
            assertEquals(200, response2.getStatusCode());
            handler.waitForCompletion(30, SECONDS);

            // The second request should hit the connection pool (HTTP/2 registry) and NOT
            // open a new connection ��� no DNS resolution, no TLS handshake
            var events = handler.firedEvents;
            assertTrue(events.contains(CONNECTION_POOL_EVENT), "Should attempt pool lookup");
            assertFalse(events.contains(HOSTNAME_RESOLUTION_EVENT),
                    "Should NOT resolve hostname for reused H2 connection");
            assertFalse(events.contains(TLS_HANDSHAKE_EVENT),
                    "Should NOT do TLS handshake for reused H2 connection");
        }
    }

    @Test
    public void http2SequentialRequestsWithMaxConnectionsPerHostOne() throws Exception {
        // Verify that with maxConnectionsPerHost=1, sequential HTTP/2 requests don't deadlock
        try (AsyncHttpClient client = http2ClientWithConfig(b -> b.setMaxConnectionsPerHost(1))) {
            for (int i = 0; i < 5; i++) {
                Response response = client.prepareGet(httpsUrl("/ok")).execute().get(30, SECONDS);
                assertEquals(200, response.getStatusCode());
            }
        }
    }
}