Skip to content

Commit 93a0798

Browse files
committed
perf: streamline HTTP/2 request header copy (iteratorCharSequence + reuse toRelativeUrl for :path)
1 parent ed2c5c0 commit 93a0798

4 files changed

Lines changed: 245 additions & 17 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/*
2+
* Copyright (c) 2026 AsyncHttpClient Project. All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.asynchttpclient.bench;
17+
18+
import io.netty.handler.codec.http.DefaultHttpHeaders;
19+
import io.netty.handler.codec.http.HttpHeaderNames;
20+
import io.netty.handler.codec.http.HttpHeaders;
21+
import io.netty.handler.codec.http2.DefaultHttp2Headers;
22+
import io.netty.handler.codec.http2.Http2Headers;
23+
import io.netty.util.AsciiString;
24+
import org.openjdk.jmh.annotations.Benchmark;
25+
import org.openjdk.jmh.annotations.BenchmarkMode;
26+
import org.openjdk.jmh.annotations.Mode;
27+
import org.openjdk.jmh.annotations.OutputTimeUnit;
28+
import org.openjdk.jmh.annotations.Scope;
29+
import org.openjdk.jmh.annotations.Setup;
30+
import org.openjdk.jmh.annotations.State;
31+
32+
import java.util.Arrays;
33+
import java.util.HashSet;
34+
import java.util.Iterator;
35+
import java.util.Locale;
36+
import java.util.Map;
37+
import java.util.Set;
38+
import java.util.concurrent.TimeUnit;
39+
40+
/**
41+
* Measures the per-request HTTP/2 header copy in {@code NettyRequestSender.sendHttp2Frames}: the
42+
* baseline (String-typed {@code forEach} + {@code toLowerCase()} + {@code HashSet} skip-set) versus
43+
* {@link HttpHeaders#iteratorCharSequence()} + {@link AsciiString#contentEqualsIgnoreCase}. Both still
44+
* lowercase forwarded names (Netty's validating {@link DefaultHttp2Headers} rejects uppercase), so the
45+
* header set includes mixed-case names to exercise that path.
46+
*/
47+
@State(Scope.Thread)
48+
@BenchmarkMode(Mode.AverageTime)
49+
@OutputTimeUnit(TimeUnit.NANOSECONDS)
50+
public class Http2HeaderConversionBenchmark {
51+
52+
private static final Set<String> EXCLUDED_STRING = new HashSet<>(Arrays.asList(
53+
"connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade", "host"));
54+
55+
private HttpHeaders headers;
56+
57+
@Setup
58+
public void setup() {
59+
// Representative request header set built the way NettyRequestFactory builds it:
60+
// names are AsciiString constants from HttpHeaderNames.
61+
headers = new DefaultHttpHeaders(false);
62+
headers.set(HttpHeaderNames.HOST, "www.example.com");
63+
headers.set(HttpHeaderNames.USER_AGENT, "AHC/3.0");
64+
headers.set(HttpHeaderNames.ACCEPT, "*/*");
65+
headers.set(HttpHeaderNames.ACCEPT_ENCODING, "gzip, deflate");
66+
headers.set(HttpHeaderNames.CONTENT_TYPE, "application/json; charset=utf-8");
67+
headers.set(HttpHeaderNames.CONTENT_LENGTH, "256");
68+
headers.set(HttpHeaderNames.AUTHORIZATION, "Bearer abcdef0123456789");
69+
headers.set(HttpHeaderNames.COOKIE, "session=deadbeef; theme=dark");
70+
headers.set(HttpHeaderNames.CONNECTION, "keep-alive");
71+
headers.set(HttpHeaderNames.CACHE_CONTROL, "no-cache");
72+
// Mixed-case user-supplied names (stored as String, original casing preserved) — these are the
73+
// names the proposed path must lowercase, since validating DefaultHttp2Headers rejects uppercase.
74+
headers.add("X-Request-ID", "abc-123-def-456");
75+
headers.add("X-Custom-Header", "some-custom-value");
76+
}
77+
78+
/** Exact reproduction of production NettyRequestSender.sendHttp2Frames header loop. */
79+
@Benchmark
80+
public Http2Headers baseline_forEach_toLowerCase() {
81+
Http2Headers h2 = new DefaultHttp2Headers()
82+
.method("GET").path("/path?q=1").scheme("https").authority("www.example.com");
83+
for (Map.Entry<String, String> entry : headers) {
84+
String name = entry.getKey().toLowerCase();
85+
if (!EXCLUDED_STRING.contains(name)) {
86+
h2.add(name, entry.getValue());
87+
}
88+
}
89+
return h2;
90+
}
91+
92+
/** Proposed: iteratorCharSequence + AsciiString-keyed case-insensitive skip set. */
93+
@Benchmark
94+
public Http2Headers proposed_charSequence_ascii() {
95+
Http2Headers h2 = new DefaultHttp2Headers()
96+
.method("GET").path("/path?q=1").scheme("https").authority("www.example.com");
97+
Iterator<Map.Entry<CharSequence, CharSequence>> it = headers.iteratorCharSequence();
98+
while (it.hasNext()) {
99+
Map.Entry<CharSequence, CharSequence> entry = it.next();
100+
CharSequence name = entry.getKey();
101+
if (!containsIgnoreCase(name)) {
102+
h2.add(toLowerCaseName(name), entry.getValue());
103+
}
104+
}
105+
return h2;
106+
}
107+
108+
/** Mirrors production NettyRequestSender.toLowerCaseHeaderName — allocation-free when already lowercase. */
109+
private static CharSequence toLowerCaseName(CharSequence name) {
110+
if (name instanceof AsciiString) {
111+
return ((AsciiString) name).toLowerCase();
112+
}
113+
return name.toString().toLowerCase(Locale.ROOT);
114+
}
115+
116+
private static boolean containsIgnoreCase(CharSequence name) {
117+
// Direct constant comparison: HTTP/2 forbids exactly these 6 connection-specific names.
118+
// AsciiString.contentEqualsIgnoreCase short-circuits on length mismatch (cheap).
119+
return HttpHeaderNames.CONNECTION.contentEqualsIgnoreCase(name)
120+
|| HttpHeaderNames.HOST.contentEqualsIgnoreCase(name)
121+
|| HttpHeaderNames.TRANSFER_ENCODING.contentEqualsIgnoreCase(name)
122+
|| HttpHeaderNames.UPGRADE.contentEqualsIgnoreCase(name)
123+
|| HttpHeaderNames.KEEP_ALIVE.contentEqualsIgnoreCase(name)
124+
|| HttpHeaderNames.PROXY_CONNECTION.contentEqualsIgnoreCase(name);
125+
}
126+
}

client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import io.netty.channel.ChannelPromise;
2525
import io.netty.handler.codec.http.DefaultFullHttpRequest;
2626
import io.netty.handler.codec.http.DefaultHttpHeaders;
27+
import io.netty.handler.codec.http.HttpHeaderNames;
2728
import io.netty.handler.codec.http.HttpHeaderValues;
2829
import io.netty.handler.codec.http.HttpHeaders;
2930
import io.netty.handler.codec.http.HttpMethod;
@@ -36,6 +37,7 @@
3637
import io.netty.handler.codec.http2.Http2StreamChannelBootstrap;
3738
import io.netty.resolver.AddressResolver;
3839
import io.netty.resolver.AddressResolverGroup;
40+
import io.netty.util.AsciiString;
3941
import io.netty.util.ReferenceCountUtil;
4042
import io.netty.util.Timer;
4143
import io.netty.util.concurrent.Future;
@@ -81,13 +83,14 @@
8183
import java.io.IOException;
8284
import java.net.InetSocketAddress;
8385
import java.net.SocketAddress;
86+
import java.util.Iterator;
8487
import java.util.List;
85-
import java.util.Set;
88+
import java.util.Locale;
89+
import java.util.Map;
8690

8791
import static io.netty.handler.codec.http.HttpHeaderNames.EXPECT;
8892
import static java.util.Collections.singletonList;
8993
import static java.util.Objects.requireNonNull;
90-
import static java.util.Set.of;
9194
import static org.asynchttpclient.util.AuthenticatorUtils.perConnectionAuthorizationHeader;
9295
import static org.asynchttpclient.util.AuthenticatorUtils.perConnectionProxyAuthorizationHeader;
9396
import static org.asynchttpclient.util.HttpConstants.Methods.CONNECT;
@@ -420,12 +423,33 @@ private <T> NettyResponseFuture<T> newNettyResponseFuture(Request request, Async
420423
}
421424

422425
/**
423-
* HTTP/2 connection-specific headers that must NOT be forwarded as per RFC 7540 §8.1.2.2.
424-
* These are HTTP/1.1 connection-specific headers that have no meaning in HTTP/2.
426+
* Whether {@code name} is a connection-specific header forbidden in HTTP/2 (RFC 7540 §8.1.2.2).
427+
* Matched case-insensitively against the {@link HttpHeaderNames} {@link AsciiString} constants, so the
428+
* per-request header copy needs no {@link String}/{@code toLowerCase} allocation to run this check.
425429
*/
426-
private static final Set<String> HTTP2_EXCLUDED_HEADERS = of(
427-
"connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade", "host"
428-
);
430+
private static boolean isHttp2ExcludedHeader(CharSequence name) {
431+
return HttpHeaderNames.CONNECTION.contentEqualsIgnoreCase(name)
432+
|| HttpHeaderNames.HOST.contentEqualsIgnoreCase(name)
433+
|| HttpHeaderNames.TRANSFER_ENCODING.contentEqualsIgnoreCase(name)
434+
|| HttpHeaderNames.UPGRADE.contentEqualsIgnoreCase(name)
435+
|| HttpHeaderNames.KEEP_ALIVE.contentEqualsIgnoreCase(name)
436+
|| HttpHeaderNames.PROXY_CONNECTION.contentEqualsIgnoreCase(name);
437+
}
438+
439+
/**
440+
* Lower-cases an HTTP/1.1 header name for HTTP/2, allocating nothing when it is already lowercase.
441+
* Netty's validating {@link DefaultHttp2Headers} throws {@link io.netty.handler.codec.http2.Http2Exception}
442+
* on a name with any uppercase ASCII letter (it does not normalise), so mixed-case user names must be
443+
* lowercased before they are added. {@link AsciiString#toLowerCase()} and {@link String#toLowerCase(Locale)}
444+
* both return the same instance when nothing changes, so already-lowercase names — AHC's own
445+
* {@link HttpHeaderNames} constants — allocate nothing.
446+
*/
447+
private static CharSequence toLowerCaseHeaderName(CharSequence name) {
448+
if (name instanceof AsciiString) {
449+
return ((AsciiString) name).toLowerCase();
450+
}
451+
return name.toString().toLowerCase(Locale.ROOT);
452+
}
429453

430454
public <T> void writeRequest(NettyResponseFuture<T> future, Channel channel) {
431455
// if the channel is dead because it was pooled and the remote server decided to close it,
@@ -575,21 +599,25 @@ private <T> void sendHttp2Frames(NettyResponseFuture<T> future, Http2StreamChann
575599
Uri uri = future.getUri();
576600

577601
try {
578-
// Build HTTP/2 pseudo-headers + regular headers
602+
// Build HTTP/2 pseudo-headers + regular headers. :path reuses Uri.toRelativeUrl() (pooled
603+
// StringBuilder) instead of re-concatenating path + "?" + query on every request.
579604
Http2Headers h2Headers = new DefaultHttp2Headers()
580605
.method(httpRequest.method().name())
581-
.path(uri.getNonEmptyPath() + (uri.getQuery() != null ? "?" + uri.getQuery() : ""))
606+
.path(uri.toRelativeUrl())
582607
.scheme(uri.getScheme())
583608
.authority(hostHeader(uri));
584609

585-
// Copy HTTP/1.1 headers, skipping connection-specific ones that are forbidden in HTTP/2.
586-
// RFC 7540 §8.1.2 requires all header field names to be lowercase in HTTP/2.
587-
httpRequest.headers().forEach(entry -> {
588-
String name = entry.getKey().toLowerCase();
589-
if (!HTTP2_EXCLUDED_HEADERS.contains(name)) {
590-
h2Headers.add(name, entry.getValue());
610+
// Copy the HTTP/1.1 headers, dropping connection-specific names forbidden in HTTP/2 (RFC 7540
611+
// §8.1.2.2). iteratorCharSequence() avoids the per-name String the String-typed iterator forces;
612+
// see isHttp2ExcludedHeader and toLowerCaseHeaderName for the skip-check and lowercasing rules.
613+
Iterator<Map.Entry<CharSequence, CharSequence>> it = httpRequest.headers().iteratorCharSequence();
614+
while (it.hasNext()) {
615+
Map.Entry<CharSequence, CharSequence> entry = it.next();
616+
CharSequence name = entry.getKey();
617+
if (!isHttp2ExcludedHeader(name)) {
618+
h2Headers.add(toLowerCaseHeaderName(name), entry.getValue());
591619
}
592-
});
620+
}
593621

594622
// Determine if we have a body to write.
595623
// Support both DefaultFullHttpRequest (inline content) and NettyDirectBody (byte array/buffer bodies).

client/src/test/java/org/asynchttpclient/BasicHttp2Test.java

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,14 +262,21 @@ private void sendEchoResponse(ChannelHandlerContext ctx, ByteBuf body, String fu
262262
responseHeaders.add("x-querystring", queryString);
263263
}
264264

265-
// Echo request headers as X-{name}
265+
// Echo request headers as X-{name}, and also report the exact (case-preserving) set of
266+
// received non-pseudo header names in a single value so wire casing / exclusion is testable.
267+
StringBuilder receivedNames = new StringBuilder();
266268
for (Map.Entry<CharSequence, CharSequence> entry : requestHeaders) {
267269
String name = entry.getKey().toString();
268270
// Skip pseudo-headers
269271
if (!name.startsWith(":")) {
270272
responseHeaders.add("x-" + name, entry.getValue());
273+
if (receivedNames.length() > 0) {
274+
receivedNames.append(',');
275+
}
276+
receivedNames.append(name);
271277
}
272278
}
279+
responseHeaders.add("x-received-names", receivedNames.toString());
273280

274281
// Handle OPTIONS
275282
if ("OPTIONS".equalsIgnoreCase(method)) {
@@ -768,6 +775,51 @@ public void getWithHeadersOverHttp2() throws Exception {
768775
}
769776
}
770777

778+
/**
779+
* Regression guard for the HTTP/2 header copy: a user-supplied mixed-case header name must be
780+
* lowercased before it reaches the validating {@link DefaultHttp2Headers}, which otherwise throws
781+
* {@code Http2Exception: invalid header name}. Asserts (a) the request succeeds, (b) the name is
782+
* lowercase on the wire, and (c) connection-specific names are not forwarded.
783+
*/
784+
@Test
785+
public void mixedCaseHeaderIsLowercasedAndConnectionHeadersExcludedOverHttp2() throws Exception {
786+
try (AsyncHttpClient client = http2Client()) {
787+
Response response = client.prepareGet(httpsUrl("/echo"))
788+
.addHeader("X-Mixed-Case", "v1")
789+
.addHeader("Another-Custom-Header", "v2")
790+
.addHeader("connection", "keep-alive")
791+
.addHeader("Keep-Alive", "timeout=5")
792+
.addHeader("Upgrade", "h2c")
793+
.execute()
794+
.get(30, SECONDS);
795+
796+
assertEquals(200, response.getStatusCode());
797+
798+
// x-received-names reports the exact, case-preserving names the server decoded off the wire.
799+
String received = response.getHeader("x-received-names");
800+
assertNotNull(received, "server should report received header names");
801+
List<String> names = Arrays.asList(received.split(","));
802+
803+
// (b) mixed-case user headers arrive lowercase on the wire (case-sensitive membership check)
804+
assertTrue(names.contains("x-mixed-case"),
805+
"mixed-case header should be lowercase on the wire, got: " + received);
806+
assertTrue(names.contains("another-custom-header"),
807+
"mixed-case header should be lowercase on the wire, got: " + received);
808+
assertFalse(names.contains("X-Mixed-Case"),
809+
"uppercase header name must not appear on the wire, got: " + received);
810+
811+
// (c) connection-specific names (any casing) are dropped, not forwarded as regular headers
812+
for (String forbidden : new String[]{"connection", "keep-alive", "upgrade", "host"}) {
813+
assertFalse(names.contains(forbidden),
814+
"connection-specific header '" + forbidden + "' must be excluded, got: " + received);
815+
}
816+
817+
// and the values still round-trip for the forwarded headers
818+
assertEquals("v1", response.getHeader("X-x-mixed-case"));
819+
assertEquals("v2", response.getHeader("X-another-custom-header"));
820+
}
821+
}
822+
771823
@Test
772824
public void postWithHeadersAndFormParamsOverHttp2() throws Exception {
773825
try (AsyncHttpClient client = http2Client()) {

client/src/test/java/org/asynchttpclient/uri/UriTest.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,4 +352,26 @@ public void testGetPathWhenPathIsEmpty() {
352352
Uri uri = Uri.create("http://stackoverflow.com");
353353
assertEquals("/", uri.getNonEmptyPath(), "Incorrect path returned from getNonEmptyPath");
354354
}
355+
356+
/**
357+
* The HTTP/2 writer builds the {@code :path} pseudo-header via {@link Uri#toRelativeUrl()} instead of
358+
* the older {@code getNonEmptyPath() + (query != null ? "?" + query : "")} concatenation; this locks in
359+
* that the two are byte-identical for representative origin-form request targets (no wire change).
360+
*/
361+
@RepeatedIfExceptionsTest(repeats = 5)
362+
public void testToRelativeUrlMatchesLegacyPathConcat() {
363+
for (String url : new String[]{
364+
"http://example.com", // empty path
365+
"http://example.com/", // root path
366+
"http://example.com/a/b", // path, no query
367+
"http://example.com/a/b?x=1&y=2", // path + query
368+
"http://example.com/?q=1", // root path + query
369+
"http://example.com/search?q=a%20b&n=1" // encoded query
370+
}) {
371+
Uri uri = Uri.create(url);
372+
String legacy = uri.getNonEmptyPath() + (uri.getQuery() != null ? "?" + uri.getQuery() : "");
373+
assertEquals(legacy, uri.toRelativeUrl(),
374+
"toRelativeUrl() must equal the legacy :path concatenation for " + url);
375+
}
376+
}
355377
}

0 commit comments

Comments
 (0)