Skip to content

Commit 8f8e6db

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

4 files changed

Lines changed: 272 additions & 16 deletions

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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+
package org.asynchttpclient.bench;
11+
12+
import io.netty.handler.codec.http.DefaultHttpHeaders;
13+
import io.netty.handler.codec.http.HttpHeaderNames;
14+
import io.netty.handler.codec.http.HttpHeaders;
15+
import io.netty.handler.codec.http2.DefaultHttp2Headers;
16+
import io.netty.handler.codec.http2.Http2Headers;
17+
import io.netty.util.AsciiString;
18+
import org.openjdk.jmh.annotations.Benchmark;
19+
import org.openjdk.jmh.annotations.BenchmarkMode;
20+
import org.openjdk.jmh.annotations.Mode;
21+
import org.openjdk.jmh.annotations.OutputTimeUnit;
22+
import org.openjdk.jmh.annotations.Scope;
23+
import org.openjdk.jmh.annotations.Setup;
24+
import org.openjdk.jmh.annotations.State;
25+
26+
import java.util.Arrays;
27+
import java.util.HashSet;
28+
import java.util.Iterator;
29+
import java.util.Locale;
30+
import java.util.Map;
31+
import java.util.Set;
32+
import java.util.concurrent.TimeUnit;
33+
34+
/**
35+
* Measures the per-request HTTP/2 header conversion done in
36+
* {@code NettyRequestSender.sendHttp2Frames}: iterating the HTTP/1.1
37+
* {@link HttpHeaders} and copying each into {@link Http2Headers}, skipping
38+
* connection-specific names.
39+
*
40+
* <p>BASELINE reproduces the production code exactly:
41+
* {@code headers.forEach(e -> { String n = e.getKey().toLowerCase(); if (!STRING_SET.contains(n)) h2.add(n, e.getValue()); })}.
42+
* The {@code forEach} forces every stored {@link AsciiString} name into a
43+
* {@link String}, then {@code toLowerCase()} allocates a second {@link String}.
44+
*
45+
* <p>PROPOSED uses {@link HttpHeaders#iteratorCharSequence()} (no String
46+
* conversion) and matches against the {@link HttpHeaderNames}
47+
* {@link AsciiString} constants case-insensitively. It still lowercases each
48+
* forwarded name (HTTP/2 requires lowercase names and Netty's validating
49+
* {@link DefaultHttp2Headers} throws on an uppercase name rather than
50+
* normalising it), but allocation-free for already-lowercase names since
51+
* {@link AsciiString#toLowerCase()} / {@link String#toLowerCase(Locale)}
52+
* return the same instance when there is nothing to change. The header set below
53+
* therefore includes mixed-case user-supplied names to exercise that path.
54+
*/
55+
@State(Scope.Thread)
56+
@BenchmarkMode(Mode.AverageTime)
57+
@OutputTimeUnit(TimeUnit.NANOSECONDS)
58+
public class Http2HeaderConversionBenchmark {
59+
60+
private static final Set<String> EXCLUDED_STRING = new HashSet<>(Arrays.asList(
61+
"connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade", "host"));
62+
63+
private static final Set<AsciiString> EXCLUDED_ASCII = new HashSet<>(Arrays.asList(
64+
HttpHeaderNames.CONNECTION, HttpHeaderNames.KEEP_ALIVE, HttpHeaderNames.PROXY_CONNECTION,
65+
HttpHeaderNames.TRANSFER_ENCODING, HttpHeaderNames.UPGRADE, HttpHeaderNames.HOST));
66+
67+
private HttpHeaders headers;
68+
69+
@Setup
70+
public void setup() {
71+
// Representative request header set built the way NettyRequestFactory builds it:
72+
// names are AsciiString constants from HttpHeaderNames.
73+
headers = new DefaultHttpHeaders(false);
74+
headers.set(HttpHeaderNames.HOST, "www.example.com");
75+
headers.set(HttpHeaderNames.USER_AGENT, "AHC/3.0");
76+
headers.set(HttpHeaderNames.ACCEPT, "*/*");
77+
headers.set(HttpHeaderNames.ACCEPT_ENCODING, "gzip, deflate");
78+
headers.set(HttpHeaderNames.CONTENT_TYPE, "application/json; charset=utf-8");
79+
headers.set(HttpHeaderNames.CONTENT_LENGTH, "256");
80+
headers.set(HttpHeaderNames.AUTHORIZATION, "Bearer abcdef0123456789");
81+
headers.set(HttpHeaderNames.COOKIE, "session=deadbeef; theme=dark");
82+
headers.set(HttpHeaderNames.CONNECTION, "keep-alive");
83+
headers.set(HttpHeaderNames.CACHE_CONTROL, "no-cache");
84+
// Mixed-case user-supplied names (stored as String, original casing preserved) — these are the
85+
// names the proposed path must lowercase, since validating DefaultHttp2Headers rejects uppercase.
86+
headers.add("X-Request-ID", "abc-123-def-456");
87+
headers.add("X-Custom-Header", "some-custom-value");
88+
}
89+
90+
/** Exact reproduction of production NettyRequestSender.sendHttp2Frames header loop. */
91+
@Benchmark
92+
public Http2Headers baseline_forEach_toLowerCase() {
93+
Http2Headers h2 = new DefaultHttp2Headers()
94+
.method("GET").path("/path?q=1").scheme("https").authority("www.example.com");
95+
for (Map.Entry<String, String> entry : headers) {
96+
String name = entry.getKey().toLowerCase();
97+
if (!EXCLUDED_STRING.contains(name)) {
98+
h2.add(name, entry.getValue());
99+
}
100+
}
101+
return h2;
102+
}
103+
104+
/** Proposed: iteratorCharSequence + AsciiString-keyed case-insensitive skip set. */
105+
@Benchmark
106+
public Http2Headers proposed_charSequence_ascii() {
107+
Http2Headers h2 = new DefaultHttp2Headers()
108+
.method("GET").path("/path?q=1").scheme("https").authority("www.example.com");
109+
Iterator<Map.Entry<CharSequence, CharSequence>> it = headers.iteratorCharSequence();
110+
while (it.hasNext()) {
111+
Map.Entry<CharSequence, CharSequence> entry = it.next();
112+
CharSequence name = entry.getKey();
113+
if (!containsIgnoreCase(name)) {
114+
h2.add(toLowerCaseName(name), entry.getValue());
115+
}
116+
}
117+
return h2;
118+
}
119+
120+
/** Mirrors production NettyRequestSender.toLowerCaseHeaderName — allocation-free when already lowercase. */
121+
private static CharSequence toLowerCaseName(CharSequence name) {
122+
if (name instanceof AsciiString) {
123+
return ((AsciiString) name).toLowerCase();
124+
}
125+
return name.toString().toLowerCase(Locale.ROOT);
126+
}
127+
128+
private static boolean containsIgnoreCase(CharSequence name) {
129+
// Direct constant comparison: HTTP/2 forbids exactly these 6 connection-specific names.
130+
// AsciiString.contentEqualsIgnoreCase short-circuits on length mismatch (cheap).
131+
return HttpHeaderNames.CONNECTION.contentEqualsIgnoreCase(name)
132+
|| HttpHeaderNames.HOST.contentEqualsIgnoreCase(name)
133+
|| HttpHeaderNames.TRANSFER_ENCODING.contentEqualsIgnoreCase(name)
134+
|| HttpHeaderNames.UPGRADE.contentEqualsIgnoreCase(name)
135+
|| HttpHeaderNames.KEEP_ALIVE.contentEqualsIgnoreCase(name)
136+
|| HttpHeaderNames.PROXY_CONNECTION.contentEqualsIgnoreCase(name);
137+
}
138+
}

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

Lines changed: 58 additions & 15 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,42 @@ 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 HTTP/1.1 header that must NOT be forwarded over
427+
* HTTP/2 (RFC 7540 §8.1.2.2). These have no meaning in HTTP/2 and some servers RST the stream if
428+
* they are present.
429+
* <p>
430+
* Matched case-insensitively against the {@link HttpHeaderNames} {@link AsciiString}
431+
* constants so the per-request header copy needs no {@link String}/{@code toLowerCase} allocation;
432+
* the length-mismatch short-circuit in {@code contentEqualsIgnoreCase} keeps this chain cheap and
433+
* branch-predictable.
425434
*/
426-
private static final Set<String> HTTP2_EXCLUDED_HEADERS = of(
427-
"connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade", "host"
428-
);
435+
private static boolean isHttp2ExcludedHeader(CharSequence name) {
436+
return HttpHeaderNames.CONNECTION.contentEqualsIgnoreCase(name)
437+
|| HttpHeaderNames.HOST.contentEqualsIgnoreCase(name)
438+
|| HttpHeaderNames.TRANSFER_ENCODING.contentEqualsIgnoreCase(name)
439+
|| HttpHeaderNames.UPGRADE.contentEqualsIgnoreCase(name)
440+
|| HttpHeaderNames.KEEP_ALIVE.contentEqualsIgnoreCase(name)
441+
|| HttpHeaderNames.PROXY_CONNECTION.contentEqualsIgnoreCase(name);
442+
}
443+
444+
/**
445+
* Lower-cases an HTTP/1.1 header name for use as an HTTP/2 header name, allocating nothing when the
446+
* name is already lowercase.
447+
* <p>
448+
* HTTP/2 requires lowercase header field names (RFC 7540 §8.1.2) and Netty's validating
449+
* {@link DefaultHttp2Headers} throws {@link io.netty.handler.codec.http2.Http2Exception} on any name
450+
* containing an uppercase ASCII letter, so mixed-case user-supplied names must be normalised before
451+
* they are added. Both {@link AsciiString#toLowerCase()} and
452+
* {@link String#toLowerCase(Locale)} return the same instance when there is nothing to change, so the
453+
* common case — AHC's own names built from lowercase {@link HttpHeaderNames} constants — allocates
454+
* nothing; only genuinely mixed-case names allocate, exactly as the previous {@code toLowerCase()} did.
455+
*/
456+
private static CharSequence toLowerCaseHeaderName(CharSequence name) {
457+
if (name instanceof AsciiString) {
458+
return ((AsciiString) name).toLowerCase();
459+
}
460+
return name.toString().toLowerCase(Locale.ROOT);
461+
}
429462

430463
public <T> void writeRequest(NettyResponseFuture<T> future, Channel channel) {
431464
// if the channel is dead because it was pooled and the remote server decided to close it,
@@ -575,21 +608,31 @@ private <T> void sendHttp2Frames(NettyResponseFuture<T> future, Http2StreamChann
575608
Uri uri = future.getUri();
576609

577610
try {
578-
// Build HTTP/2 pseudo-headers + regular headers
611+
// Build HTTP/2 pseudo-headers + regular headers.
612+
// :path reuses the origin-form request-target already computed for HTTP/1.1 via the pooled
613+
// StringBuilder (Uri.toRelativeUrl), instead of re-concatenating path + "?" + query here.
579614
Http2Headers h2Headers = new DefaultHttp2Headers()
580615
.method(httpRequest.method().name())
581-
.path(uri.getNonEmptyPath() + (uri.getQuery() != null ? "?" + uri.getQuery() : ""))
616+
.path(uri.toRelativeUrl())
582617
.scheme(uri.getScheme())
583618
.authority(hostHeader(uri));
584619

585620
// 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());
621+
// RFC 7540 §8.1.2 requires all header field names to be lowercase in HTTP/2, and Netty's
622+
// validating DefaultHttp2Headers REJECTS (throws Http2Exception) any name containing an
623+
// uppercase ASCII letter — it does NOT normalise — so user-supplied mixed-case names must
624+
// be lowercased here. iteratorCharSequence() yields the stored CharSequence names without
625+
// the per-name String materialisation the String-typed iterator forces; toLowerCaseHeaderName
626+
// then returns the name unchanged (no allocation) when it is already lowercase, which is the
627+
// common case since AHC sets its own names from lowercase HttpHeaderNames.* constants.
628+
Iterator<Map.Entry<CharSequence, CharSequence>> it = httpRequest.headers().iteratorCharSequence();
629+
while (it.hasNext()) {
630+
Map.Entry<CharSequence, CharSequence> entry = it.next();
631+
CharSequence name = entry.getKey();
632+
if (!isHttp2ExcludedHeader(name)) {
633+
h2Headers.add(toLowerCaseHeaderName(name), entry.getValue());
591634
}
592-
});
635+
}
593636

594637
// Determine if we have a body to write.
595638
// 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 rewrite (proposal 019): a user-supplied mixed-case
780+
* header name must be lowercased before it reaches the validating {@link DefaultHttp2Headers}, which
781+
* otherwise throws {@code Http2Exception: invalid header name}. Asserts (a) the request succeeds,
782+
* (b) the name is 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: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,4 +352,27 @@ 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
359+
* in that the two are byte-identical for representative origin-form request targets, so the switch is
360+
* not an observable wire change (see proposal 021).
361+
*/
362+
@RepeatedIfExceptionsTest(repeats = 5)
363+
public void testToRelativeUrlMatchesLegacyPathConcat() {
364+
for (String url : new String[]{
365+
"http://example.com", // empty path
366+
"http://example.com/", // root path
367+
"http://example.com/a/b", // path, no query
368+
"http://example.com/a/b?x=1&y=2", // path + query
369+
"http://example.com/?q=1", // root path + query
370+
"http://example.com/search?q=a%20b&n=1" // encoded query
371+
}) {
372+
Uri uri = Uri.create(url);
373+
String legacy = uri.getNonEmptyPath() + (uri.getQuery() != null ? "?" + uri.getQuery() : "");
374+
assertEquals(legacy, uri.toRelativeUrl(),
375+
"toRelativeUrl() must equal the legacy :path concatenation for " + url);
376+
}
377+
}
355378
}

0 commit comments

Comments
 (0)