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..b1fa1d3fc 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,8 @@ 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.GZIP_DEFLATE_HPACK; import static org.asynchttpclient.util.HttpUtils.hostHeader; import static org.asynchttpclient.util.MiscUtils.getCause; import static org.asynchttpclient.util.ProxyUtils.getProxyServer; @@ -933,7 +935,7 @@ private void sendHttp2Frames(NettyResponseFuture future, Http2StreamChann && !HttpHeaderValues.TRAILERS.contentEqualsIgnoreCase(value)) { continue; } - h2Headers.add(toLowerCaseHeaderName(name), value); + h2Headers.add(toLowerCaseHeaderName(name), http2HeaderValue(name, value)); } // Determine the body to send: an in-memory buffer (DefaultFullHttpRequest content or a @@ -971,6 +973,13 @@ private void sendHttp2Frames(NettyResponseFuture future, Http2StreamChann } } + private static CharSequence http2HeaderValue(CharSequence name, CharSequence value) { + if (value == GZIP_DEFLATE && HttpHeaderNames.ACCEPT_ENCODING.contentEqualsIgnoreCase(name)) { + return GZIP_DEFLATE_HPACK; + } + 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/main/java/org/asynchttpclient/util/HttpUtils.java b/client/src/main/java/org/asynchttpclient/util/HttpUtils.java index 3cca41e61..32afd9142 100644 --- a/client/src/main/java/org/asynchttpclient/util/HttpUtils.java +++ b/client/src/main/java/org/asynchttpclient/util/HttpUtils.java @@ -37,6 +37,8 @@ public final class HttpUtils { public static final AsciiString ACCEPT_ALL_HEADER_VALUE = new AsciiString("*/*"); public static final AsciiString GZIP_DEFLATE = new AsciiString(HttpHeaderValues.GZIP + "," + HttpHeaderValues.DEFLATE); + /** RFC 7541 Appendix A static table entry 16. */ + public static final AsciiString GZIP_DEFLATE_HPACK = new AsciiString(HttpHeaderValues.GZIP + ", " + HttpHeaderValues.DEFLATE); private static final String CONTENT_TYPE_CHARSET_ATTRIBUTE = "charset="; private static final String CONTENT_TYPE_BOUNDARY_ATTRIBUTE = "boundary="; private static final String BROTLY_ACCEPT_ENCODING_SUFFIX = ", br"; diff --git a/client/src/test/java/org/asynchttpclient/BasicHttp2Test.java b/client/src/test/java/org/asynchttpclient/BasicHttp2Test.java index 9e97a7b42..877b2c3a3 100644 --- a/client/src/test/java/org/asynchttpclient/BasicHttp2Test.java +++ b/client/src/test/java/org/asynchttpclient/BasicHttp2Test.java @@ -26,6 +26,8 @@ import io.netty.channel.group.DefaultChannelGroup; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.nio.NioServerSocketChannel; +import io.netty.handler.codec.compression.Brotli; +import io.netty.handler.codec.compression.Zstd; import io.netty.handler.codec.http.HttpHeaderNames; import io.netty.handler.codec.http.HttpHeaderValues; import io.netty.handler.codec.http.QueryStringDecoder; @@ -75,6 +77,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 +1051,39 @@ 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()); + List expected = new ArrayList<>(); + expected.add("gzip, deflate"); + if (Brotli.isAvailable()) { + expected.add("br"); + } + if (Zstd.isAvailable()) { + expected.add("zstd"); + } + assertEquals(expected, response.getHeaders("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/BasicHttpTest.java b/client/src/test/java/org/asynchttpclient/BasicHttpTest.java index 44e7f45df..0e0cff044 100755 --- a/client/src/test/java/org/asynchttpclient/BasicHttpTest.java +++ b/client/src/test/java/org/asynchttpclient/BasicHttpTest.java @@ -64,6 +64,7 @@ import static io.netty.handler.codec.http.HttpHeaderNames.TRANSFER_ENCODING; 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.Dsl.get; import static org.asynchttpclient.Dsl.head; @@ -106,6 +107,18 @@ private String getTargetUrl() { return server.getHttpUrl() + "/foo/bar"; } + @RepeatedIfExceptionsTest(repeats = 5) + public void generatedAcceptEncodingKeepsHttp1Spelling() throws Exception { + server.enqueueEcho(); + try (AsyncHttpClient client = asyncHttpClient(config().setCompressionEnforced(true))) { + Response response = client.prepareGet(getTargetUrl()) + .execute() + .get(TIMEOUT, SECONDS); + + assertEquals("gzip,deflate", response.getHeader("X-Accept-Encoding")); + } + } + @RepeatedIfExceptionsTest(repeats = 5) public void getRootUrl() throws Throwable { withClient().run(client -> 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..bd0adba56 --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/netty/request/AcceptEncodingHpackTest.java @@ -0,0 +1,52 @@ +/* + * 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; + +/** Verifies Netty HPACK wire sizes; {@code BasicHttp2Test} covers AHC's production request path. */ +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(); + } + } +}