diff --git a/client/src/jmh/java/org/asynchttpclient/bench/AcceptEncodingHpackBenchmark.java b/client/src/jmh/java/org/asynchttpclient/bench/AcceptEncodingHpackBenchmark.java index 0e69aae03..31db7deca 100644 --- a/client/src/jmh/java/org/asynchttpclient/bench/AcceptEncodingHpackBenchmark.java +++ b/client/src/jmh/java/org/asynchttpclient/bench/AcceptEncodingHpackBenchmark.java @@ -34,15 +34,15 @@ /** * Measures the HPACK-encoded wire size of {@code accept-encoding} for the two value spellings: * * *

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/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()) { 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(); + } + } +}