Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,15 @@
/**
* Measures the HPACK-encoded wire size of {@code accept-encoding} for the two value spellings:
* <ul>
* <li>AHC current: {@code "gzip,deflate"} (no space) built in
* <li>AHC current: {@code "gzip,deflate"} (no space) - built in
* {@code HttpUtils.GZIP_DEFLATE = new AsciiString(GZIP + "," + DEFLATE)}.</li>
* <li>HPACK static table entry #16: {@code "gzip, deflate"} (with space, RFC 7541 App. A).</li>
* </ul>
*
* <p>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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,16 @@
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;

public final class NettyRequestSender {

private static final Logger LOGGER = LoggerFactory.getLogger(NettyRequestSender.class);
private static final AsciiString HTTP2_HPACK_GZIP_DEFLATE =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider moving this next to HttpUtils.GZIP_DEFLATE in HttpUtils rather than keeping it here. They are really the same concept, the HPACK static table spelling of the same value, and keeping them in separate files makes it easier for someone to change one and forget the other later. A short comment noting this is RFC 7541 appendix A static table entry 16 would also help since that context is currently only in the benchmark javadoc, not on the field itself.

new AsciiString(HttpHeaderValues.GZIP + ", " + HttpHeaderValues.DEFLATE);

private final AsyncHttpClientConfig config;
private final ChannelManager channelManager;
Expand Down Expand Up @@ -918,6 +921,7 @@ private <T> void sendHttp2Frames(NettyResponseFuture<T> 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());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reads the policy from future.getCurrentRequest() while the header values a few lines below come from future.getNettyRequest(). They happen to always be set together today in newNettyRequestAndResponseFuture, so this works, but it is two different reads off the same future that have to stay aligned. If you switch to the identity check on GZIP_DEFLATE mentioned below this whole method and this line can go away.

Iterator<Map.Entry<CharSequence, CharSequence>> it = httpRequest.headers().iteratorCharSequence();
while (it.hasNext()) {
Map.Entry<CharSequence, CharSequence> entry = it.next();
Expand All @@ -933,7 +937,7 @@ private <T> void sendHttp2Frames(NettyResponseFuture<T> 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
Expand Down Expand Up @@ -971,6 +975,20 @@ private <T> void sendHttp2Frames(NettyResponseFuture<T> 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)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since GZIP_DEFLATE is a singleton AsciiString and NettyRequestFactory stores that exact reference when it calls headers.set(ACCEPT_ENCODING, GZIP_DEFLATE), you can check value == GZIP_DEFLATE here instead of contentEquals. I checked and the reference does survive into the iterator, and a user typed string with the same text is never the same reference, so identity alone tells you whether this is the auto generated header without needing the separate preferHpackAcceptEncoding check at all. That would let you delete preferHpackAcceptEncoding and the getCurrentRequest call above, which removes a second read of state that has to stay in sync with what actually built the headers.

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
Expand Down
26 changes: 26 additions & 0 deletions client/src/test/java/org/asynchttpclient/BasicHttp2Test.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

response.getHeader only returns the first value for a repeated header. When compressionEnforced is true and Brotli or Zstd are available on the classpath, AHC actually sends three separate Accept-Encoding entries, not one comma joined value. Worth adding a test that runs with those codecs available and asserts on response.getHeaders("X-accept-encoding") for all values, so a rewrite that accidentally touched or duplicated the br or zstd entries would actually get caught.

}
}

@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"));
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two tests only cover HTTP/2. Since the whole point of the change is that HTTP/1 output stays as gzip,deflate with no space, it would be worth adding a small HTTP/1 test with compressionEnforced true asserting the header is still sent without the space, so that contract is pinned instead of just asserted in the PR description.


@Test
public void postWithHeadersAndFormParamsOverHttp2() throws Exception {
try (AsyncHttpClient client = http2Client()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test builds headers by hand and drives Netty's encoder directly, it does not go through AHC's own request building or NettyRequestSender at all. It is a good proof that the two spellings differ in encoded size, but it is not proof that AHC actually produces the gzip, deflate spelling in production. That part is only covered by the two BasicHttp2Test cases. Might be worth a one line comment on the class saying that explicitly so nobody later assumes this test exercises the real code path.

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();
}
}
}
Loading