From f473674d599cf75fbde72602d3f0dcad485a988d Mon Sep 17 00:00:00 2001 From: Pavel Ptashyts <49400901+pavel-ptashyts@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:08:39 +0200 Subject: [PATCH 1/3] Use HPACK spelling for HTTP/2 compression When compression is enforced, the request factory creates the default Accept-Encoding value as "gzip,deflate" for HTTP/1 compatibility. HTTP/2's HPACK static table contains "gzip, deflate", so the H2 frame writer missed the compact static entry for the generated default. Rewrite only the generated default while copying headers into HTTP/2 frames. User-supplied Accept-Encoding values keep their original spelling, and HTTP/1 output remains unchanged. Codex on behalf of Pavel Ptashyts Co-Authored-By: Codex --- .../netty/request/NettyRequestSender.java | 20 +++++++++++++- .../org/asynchttpclient/BasicHttp2Test.java | 26 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) 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 36af9019b..77fdf214f 100755 --- a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java +++ b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java @@ -106,6 +106,7 @@ 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.GZIP_DEFLATE; import static org.asynchttpclient.util.HttpUtils.hostHeader; import static org.asynchttpclient.util.MiscUtils.getCause; import static org.asynchttpclient.util.ProxyUtils.getProxyServer; @@ -113,6 +114,8 @@ public final class NettyRequestSender { private static final Logger LOGGER = LoggerFactory.getLogger(NettyRequestSender.class); + private static final AsciiString HTTP2_HPACK_GZIP_DEFLATE = + new AsciiString(HttpHeaderValues.GZIP + ", " + HttpHeaderValues.DEFLATE); private final AsyncHttpClientConfig config; private final ChannelManager channelManager; @@ -918,6 +921,7 @@ private void sendHttp2Frames(NettyResponseFuture future, Http2StreamChann // Copy the HTTP/1.1 headers, dropping connection-specific names forbidden in HTTP/2 (RFC 7540 // §8.1.2.2). iteratorCharSequence() avoids the per-name String the String-typed iterator forces; // see isHttp2ExcludedHeader and toLowerCaseHeaderName for the skip-check and lowercasing rules. + boolean preferHpackAcceptEncoding = preferHpackAcceptEncoding(future.getCurrentRequest()); Iterator> it = httpRequest.headers().iteratorCharSequence(); while (it.hasNext()) { Map.Entry entry = it.next(); @@ -933,7 +937,7 @@ private void sendHttp2Frames(NettyResponseFuture future, Http2StreamChann && !HttpHeaderValues.TRAILERS.contentEqualsIgnoreCase(value)) { continue; } - h2Headers.add(toLowerCaseHeaderName(name), value); + h2Headers.add(toLowerCaseHeaderName(name), http2HeaderValue(name, value, preferHpackAcceptEncoding)); } // Determine the body to send: an in-memory buffer (DefaultFullHttpRequest content or a @@ -971,6 +975,20 @@ private void sendHttp2Frames(NettyResponseFuture future, Http2StreamChann } } + private boolean preferHpackAcceptEncoding(Request request) { + return config.isCompressionEnforced() + && !request.getHeaders().contains(HttpHeaderNames.ACCEPT_ENCODING); + } + + private static CharSequence http2HeaderValue(CharSequence name, CharSequence value, boolean preferHpackAcceptEncoding) { + if (preferHpackAcceptEncoding + && HttpHeaderNames.ACCEPT_ENCODING.contentEqualsIgnoreCase(name) + && GZIP_DEFLATE.contentEquals(value)) { + return HTTP2_HPACK_GZIP_DEFLATE; + } + return value; + } + /** * Sends the body of an HTTP/2 request whose HEADERS were already written with {@code endStream=false} * because it carried {@code Expect: 100-continue}. Invoked by {@code Continue100Interceptor} once the diff --git a/client/src/test/java/org/asynchttpclient/BasicHttp2Test.java b/client/src/test/java/org/asynchttpclient/BasicHttp2Test.java index 9e97a7b42..ff6b0c34f 100644 --- a/client/src/test/java/org/asynchttpclient/BasicHttp2Test.java +++ b/client/src/test/java/org/asynchttpclient/BasicHttp2Test.java @@ -75,6 +75,7 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; +import static io.netty.handler.codec.http.HttpHeaderNames.ACCEPT_ENCODING; 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; @@ -1048,6 +1049,31 @@ public void mixedCaseHeaderIsLowercasedAndConnectionHeadersExcludedOverHttp2() t } } + @Test + public void generatedAcceptEncodingUsesHpackStaticValueOverHttp2() throws Exception { + try (AsyncHttpClient client = http2ClientWithConfig(builder -> builder.setCompressionEnforced(true))) { + Response response = client.prepareGet(httpsUrl("/echo")) + .execute() + .get(30, SECONDS); + + assertEquals(200, response.getStatusCode()); + assertEquals("gzip, deflate", response.getHeader("X-accept-encoding")); + } + } + + @Test + public void userAcceptEncodingSpellingIsPreservedOverHttp2() throws Exception { + try (AsyncHttpClient client = http2ClientWithConfig(builder -> builder.setCompressionEnforced(true))) { + Response response = client.prepareGet(httpsUrl("/echo")) + .setHeader(ACCEPT_ENCODING, "gzip,deflate") + .execute() + .get(30, SECONDS); + + assertEquals(200, response.getStatusCode()); + assertEquals("gzip,deflate", response.getHeader("X-accept-encoding")); + } + } + @Test public void postWithHeadersAndFormParamsOverHttp2() throws Exception { try (AsyncHttpClient client = http2Client()) { From 2d0ff49b9e057d4988a960f033e3ed181f607f68 Mon Sep 17 00:00:00 2001 From: Pavel Ptashyts <49400901+pavel-ptashyts@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:42:30 +0200 Subject: [PATCH 2/3] Verify HPACK wire-size improvement Add a deterministic encoder test proving that the HPACK static-table spelling uses a one-byte indexed representation while the old spelling requires a literal value. Clarify that the JMH benchmark reports execution time and allocation; its return value prevents dead-code elimination but is not a separate JMH metric. Codex on behalf of Pavel Ptashyts Co-Authored-By: Codex --- .../bench/AcceptEncodingHpackBenchmark.java | 4 +- .../request/AcceptEncodingHpackTest.java | 51 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 client/src/test/java/org/asynchttpclient/netty/request/AcceptEncodingHpackTest.java diff --git a/client/src/jmh/java/org/asynchttpclient/bench/AcceptEncodingHpackBenchmark.java b/client/src/jmh/java/org/asynchttpclient/bench/AcceptEncodingHpackBenchmark.java index 0e69aae03..f7749c241 100644 --- a/client/src/jmh/java/org/asynchttpclient/bench/AcceptEncodingHpackBenchmark.java +++ b/client/src/jmh/java/org/asynchttpclient/bench/AcceptEncodingHpackBenchmark.java @@ -41,8 +41,8 @@ * *

On a fresh encoder (first request of a connection) the static-table value matches as a single * indexed byte; the non-matching spelling is literal-encoded and inserted into the dynamic table. - * This bench reports {@code gc.alloc.rate.norm} and the encoded byte count via the returned buffer's - * readableBytes (consumed by the blackhole through the return value size). + * This bench reports encoding time and, when run with {@code -prof gc}, allocation. The returned encoded + * byte count prevents dead-code elimination; {@code AcceptEncodingHpackTest} verifies the wire sizes. */ @State(Scope.Thread) @BenchmarkMode(Mode.AverageTime) diff --git a/client/src/test/java/org/asynchttpclient/netty/request/AcceptEncodingHpackTest.java b/client/src/test/java/org/asynchttpclient/netty/request/AcceptEncodingHpackTest.java new file mode 100644 index 000000000..b09fa0335 --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/netty/request/AcceptEncodingHpackTest.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 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.request; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.handler.codec.http2.DefaultHttp2Headers; +import io.netty.handler.codec.http2.DefaultHttp2HeadersEncoder; +import io.netty.handler.codec.http2.Http2Headers; +import io.netty.handler.codec.http2.Http2HeadersEncoder; +import io.netty.util.AsciiString; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class AcceptEncodingHpackTest { + + private static final AsciiString ACCEPT_ENCODING = AsciiString.cached("accept-encoding"); + + @Test + public void staticTableSpellingUsesOneByteIndexedRepresentation() throws Exception { + assertTrue(encodedLength("gzip,deflate") > 1); + assertEquals(1, encodedLength("gzip, deflate")); + } + + private static int encodedLength(String value) throws Exception { + Http2HeadersEncoder encoder = new DefaultHttp2HeadersEncoder(); + Http2Headers headers = new DefaultHttp2Headers().add(ACCEPT_ENCODING, value); + ByteBuf output = Unpooled.buffer(); + try { + encoder.encodeHeaders(3, headers, output); + return output.readableBytes(); + } finally { + output.release(); + } + } +} From 527368ff973706aeedf286623c75ee9b84865fb4 Mon Sep 17 00:00:00 2001 From: Pavel Ptashyts <49400901+pavel-ptashyts@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:06:13 +0200 Subject: [PATCH 3/3] Keep HPACK benchmark ASCII Replace the remaining non-ASCII punctuation in the touched HPACK benchmark so the source follows the repository guidelines in AGENTS.md. Codex on behalf of Pavel Ptashyts Co-Authored-By: Codex --- .../org/asynchttpclient/bench/AcceptEncodingHpackBenchmark.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/jmh/java/org/asynchttpclient/bench/AcceptEncodingHpackBenchmark.java b/client/src/jmh/java/org/asynchttpclient/bench/AcceptEncodingHpackBenchmark.java index f7749c241..31db7deca 100644 --- a/client/src/jmh/java/org/asynchttpclient/bench/AcceptEncodingHpackBenchmark.java +++ b/client/src/jmh/java/org/asynchttpclient/bench/AcceptEncodingHpackBenchmark.java @@ -34,7 +34,7 @@ /** * Measures the HPACK-encoded wire size of {@code accept-encoding} for the two value spellings: *

    - *
  • AHC current: {@code "gzip,deflate"} (no space) — built in + *
  • AHC current: {@code "gzip,deflate"} (no space) - built in * {@code HttpUtils.GZIP_DEFLATE = new AsciiString(GZIP + "," + DEFLATE)}.
  • *
  • HPACK static table entry #16: {@code "gzip, deflate"} (with space, RFC 7541 App. A).
  • *