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 =
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());
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)) {
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"));
}
}

@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()) {
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 {
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