diff --git a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestFactory.java b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestFactory.java index 67d9a67be..63fd7c0ce 100755 --- a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestFactory.java +++ b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestFactory.java @@ -21,12 +21,14 @@ import io.netty.handler.codec.compression.Zstd; import io.netty.handler.codec.http.DefaultFullHttpRequest; import io.netty.handler.codec.http.DefaultHttpRequest; +import io.netty.handler.codec.http.HttpHeaderNames; import io.netty.handler.codec.http.HttpHeaderValues; import io.netty.handler.codec.http.HttpHeaders; import io.netty.handler.codec.http.HttpMethod; import io.netty.handler.codec.http.HttpRequest; import io.netty.handler.codec.http.HttpVersion; import io.netty.handler.codec.http.cookie.ClientCookieEncoder; +import io.netty.util.AsciiString; import org.asynchttpclient.AsyncHttpClientConfig; import org.asynchttpclient.Realm; import org.asynchttpclient.Request; @@ -47,6 +49,9 @@ import org.asynchttpclient.util.StringUtils; import java.nio.charset.Charset; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; import static io.netty.handler.codec.http.HttpHeaderNames.ACCEPT; import static io.netty.handler.codec.http.HttpHeaderNames.ACCEPT_ENCODING; @@ -79,6 +84,66 @@ public final class NettyRequestFactory { private static final Integer ZERO_CONTENT_LENGTH = 0; + /** + * Canonical (lowercase) header-name spelling -> the pre-built Netty {@link AsciiString} constant. + * Built once. When a user-supplied {@code String} name is byte-identical to a canonical spelling, the + * outbound header uses the shared {@code AsciiString} so {@code HttpHeadersEncoder} hits its bulk + * array-copy branch instead of the per-char US-ASCII encode loop on the event loop. The map is keyed + * case-sensitively, so a name that is not byte-identical to its canonical spelling (e.g. Train-Case + * {@code Content-Type}) misses and is emitted verbatim — on-wire bytes are never altered, and a custom + * name costs a single {@link HashMap#get} miss with zero allocation. + */ + private static final Map KNOWN_HEADER_NAMES = buildKnownHeaderNames(); + + private static Map buildKnownHeaderNames() { + AsciiString[] names = { + HttpHeaderNames.ACCEPT, + HttpHeaderNames.ACCEPT_ENCODING, + HttpHeaderNames.ACCEPT_LANGUAGE, + HttpHeaderNames.AUTHORIZATION, + HttpHeaderNames.CACHE_CONTROL, + HttpHeaderNames.CONNECTION, + HttpHeaderNames.CONTENT_LENGTH, + HttpHeaderNames.CONTENT_TYPE, + HttpHeaderNames.COOKIE, + HttpHeaderNames.HOST, + HttpHeaderNames.ORIGIN, + HttpHeaderNames.REFERER, + HttpHeaderNames.TRANSFER_ENCODING, + HttpHeaderNames.USER_AGENT + }; + Map map = new HashMap<>((int) (names.length / 0.75f) + 1); + for (AsciiString name : names) { + // Key by the constant's own (lowercase) bytes; interning only on a byte-identical match keeps + // the on-wire casing exactly as the caller spelled it. + map.put(name.toString(), name); + } + return map; + } + + /** + * Copy {@code source} request headers into the freshly created outbound {@code target}, interning + * recognized header names to their static {@link AsciiString} constant (see {@link #KNOWN_HEADER_NAMES}) + * so the encode hot path bulk-copies instead of per-char encoding. Multi-value headers and ordering are + * preserved (one {@code add} per stored entry, in iteration order). A name already stored as an + * {@code AsciiString} is on the fast path already and passed through untouched. + */ + // package-private for unit testing; not part of the public API. + static void copyInternedHeaders(HttpHeaders source, HttpHeaders target) { + Iterator> it = source.iteratorCharSequence(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + CharSequence name = entry.getKey(); + if (name instanceof String) { + AsciiString interned = KNOWN_HEADER_NAMES.get(name); + if (interned != null) { + name = interned; + } + } + target.add(name, entry.getValue()); + } + } + private final AsyncHttpClientConfig config; private final ClientCookieEncoder cookieEncoder; @@ -170,7 +235,7 @@ public NettyRequest newNettyRequest(Request request, boolean performConnectReque } else { // assign headers as configured on request - headers.set(request.getHeaders()); + copyInternedHeaders(request.getHeaders(), headers); if (isNonEmpty(request.getCookies())) { headers.set(COOKIE, cookieEncoder.encode(request.getCookies())); diff --git a/client/src/test/java/org/asynchttpclient/netty/request/NettyRequestFactoryHeaderInterningTest.java b/client/src/test/java/org/asynchttpclient/netty/request/NettyRequestFactoryHeaderInterningTest.java new file mode 100644 index 000000000..52ff4f950 --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/netty/request/NettyRequestFactoryHeaderInterningTest.java @@ -0,0 +1,128 @@ +/* + * 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.handler.codec.http.DefaultHttpHeaders; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpHeaders; +import io.netty.util.AsciiString; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests {@link NettyRequestFactory#copyInternedHeaders(HttpHeaders, HttpHeaders)}: standard header names + * supplied in canonical (lowercase) spelling must be interned to the shared {@link AsciiString} constant + * (so the encoder bulk-copies), while names that are not byte-identical to the canonical spelling, and + * custom names, must be emitted verbatim so on-wire bytes are never altered. Order, values and multi-value + * headers must be preserved. + */ +public class NettyRequestFactoryHeaderInterningTest { + + @Test + public void internsCanonicalLowercaseKnownNameToAsciiStringConstant() { + HttpHeaders source = new DefaultHttpHeaders(); + source.add("content-type", "application/json"); + + HttpHeaders target = new DefaultHttpHeaders(); + NettyRequestFactory.copyInternedHeaders(source, target); + + CharSequence name = firstNameCharSequence(target); + assertSame(HttpHeaderNames.CONTENT_TYPE, name, + "a canonical-lowercase known name must become the shared AsciiString constant"); + assertEquals("application/json", target.get("content-type")); + } + + @Test + public void leavesNonCanonicalCasingUntouched() { + HttpHeaders source = new DefaultHttpHeaders(); + // Train-Case is NOT byte-identical to the lowercase constant, so it must be emitted verbatim. + source.add("Content-Type", "application/json"); + + HttpHeaders target = new DefaultHttpHeaders(); + NettyRequestFactory.copyInternedHeaders(source, target); + + CharSequence name = firstNameCharSequence(target); + assertEquals("Content-Type", name.toString(), "wire casing must be preserved exactly"); + assertTrue(name instanceof String, "non-canonical name must remain the original String, not be interned"); + } + + @Test + public void leavesCustomNameUntouched() { + HttpHeaders source = new DefaultHttpHeaders(); + source.add("X-Custom-Header", "v1"); + + HttpHeaders target = new DefaultHttpHeaders(); + NettyRequestFactory.copyInternedHeaders(source, target); + + CharSequence name = firstNameCharSequence(target); + assertEquals("X-Custom-Header", name.toString()); + assertTrue(name instanceof String, "a custom name must pass through unchanged"); + } + + @Test + public void preservesValuesOrderAndMultiValueHeaders() { + HttpHeaders source = new DefaultHttpHeaders(); + source.add("host", "www.example.com"); + source.add("X-Multi", "a"); + source.add("X-Multi", "b"); + source.add("accept", "*/*"); + + HttpHeaders target = new DefaultHttpHeaders(); + NettyRequestFactory.copyInternedHeaders(source, target); + + // Same name->values content as a plain copy. + HttpHeaders plain = new DefaultHttpHeaders(); + plain.set(source); + assertEquals(plain.get("host"), target.get("host")); + assertEquals(plain.getAll("X-Multi"), target.getAll("X-Multi")); + assertEquals(plain.get("accept"), target.get("accept")); + assertEquals(List.of("a", "b"), target.getAll("X-Multi"), "multi-value order preserved"); + + // Iteration order preserved (host, X-Multi, X-Multi, accept). + List names = new ArrayList<>(); + for (Map.Entry e : target) { + names.add(e.getKey()); + } + assertEquals(List.of("host", "X-Multi", "X-Multi", "accept"), names); + + // The two known names are interned, the custom one is not. + boolean hostInterned = false; + boolean acceptInterned = false; + java.util.Iterator> it = target.iteratorCharSequence(); + while (it.hasNext()) { + Map.Entry e = it.next(); + if (e.getKey() == HttpHeaderNames.HOST) { + hostInterned = true; + } + if (e.getKey() == HttpHeaderNames.ACCEPT) { + acceptInterned = true; + } + } + assertTrue(hostInterned, "host should be interned to the AsciiString constant"); + assertTrue(acceptInterned, "accept should be interned to the AsciiString constant"); + } + + private static CharSequence firstNameCharSequence(HttpHeaders headers) { + return headers.iteratorCharSequence().next().getKey(); + } +}