Skip to content
Merged
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 @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<String, AsciiString> KNOWN_HEADER_NAMES = buildKnownHeaderNames();

private static Map<String, AsciiString> 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<String, AsciiString> 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<Map.Entry<CharSequence, CharSequence>> it = source.iteratorCharSequence();
while (it.hasNext()) {
Map.Entry<CharSequence, CharSequence> 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;

Expand Down Expand Up @@ -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()));
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> names = new ArrayList<>();
for (Map.Entry<String, String> 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<Map.Entry<CharSequence, CharSequence>> it = target.iteratorCharSequence();
while (it.hasNext()) {
Map.Entry<CharSequence, CharSequence> 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();
}
}