Skip to content

Commit 6423ca9

Browse files
committed
netty: Support never-indexed metadata keys
Add NettyChannelBuilder.neverIndexMetadataKey() and neverIndexMetadataKeys() so callers can mark selected outbound metadata keys for HPACK's never-indexed literal representation. High-cardinality metadata values provide little compression benefit and can churn the server's dynamic HPACK table. Keeping them out of the table avoids unnecessary insertion and eviction work while preserving dynamic indexing for other headers. Propagate an immutable set of normalized metadata names through the client transport and use it in Netty's HPACK sensitivity detector. Add unit and interoperability coverage. Generated with AI using OpenAI Codex (GPT-5).
1 parent 4ce53b1 commit 6423ca9

7 files changed

Lines changed: 327 additions & 1 deletion

netty/src/main/java/io/grpc/netty/NettyChannelBuilder.java

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import io.grpc.HttpConnectProxiedSocketAddress;
3939
import io.grpc.Internal;
4040
import io.grpc.ManagedChannelBuilder;
41+
import io.grpc.Metadata;
4142
import io.grpc.NameResolverProvider;
4243
import io.grpc.NameResolverRegistry;
4344
import io.grpc.internal.AtomicBackoff;
@@ -60,12 +61,15 @@
6061
import io.netty.channel.ReflectiveChannelFactory;
6162
import io.netty.channel.socket.nio.NioSocketChannel;
6263
import io.netty.handler.ssl.SslContext;
64+
import io.netty.util.AsciiString;
6365
import java.net.InetSocketAddress;
6466
import java.net.SocketAddress;
6567
import java.util.Collection;
6668
import java.util.Collections;
6769
import java.util.HashMap;
70+
import java.util.HashSet;
6871
import java.util.Map;
72+
import java.util.Set;
6973
import java.util.concurrent.Executor;
7074
import java.util.concurrent.ScheduledExecutorService;
7175
import java.util.concurrent.TimeUnit;
@@ -105,6 +109,7 @@ public final class NettyChannelBuilder extends ForwardingChannelBuilder2<NettyCh
105109
private ObjectPool<? extends EventLoopGroup> eventLoopGroupPool = DEFAULT_EVENT_LOOP_GROUP_POOL;
106110
private boolean autoFlowControl = DEFAULT_AUTO_FLOW_CONTROL;
107111
private int flowControlWindow = DEFAULT_FLOW_CONTROL_WINDOW;
112+
private final Set<AsciiString> neverIndexedMetadataKeys = new HashSet<>();
108113
private int maxHeaderListSize = GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE;
109114
private int softLimitHeaderListSize = GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE;
110115
private int maxInboundMessageSize = GrpcUtil.DEFAULT_MAX_MESSAGE_SIZE;
@@ -434,6 +439,35 @@ public NettyChannelBuilder flowControlWindow(int flowControlWindow) {
434439
return this;
435440
}
436441

442+
/**
443+
* Configures an outbound metadata key to use HPACK's never-indexed literal representation.
444+
*
445+
* <p>All values associated with the key's normalized name will be sent as literals and will not
446+
* be added to the peer's HPACK dynamic table. This method is additive and may be called multiple
447+
* times. Configuring the same normalized key name more than once has no additional effect. By
448+
* default, no metadata keys are configured as never indexed.
449+
*/
450+
@CanIgnoreReturnValue
451+
public NettyChannelBuilder neverIndexMetadataKey(Metadata.Key<?> key) {
452+
neverIndexedMetadataKeys.add(AsciiString.of(checkNotNull(key, "key").name()));
453+
return this;
454+
}
455+
456+
/**
457+
* Configures outbound metadata keys to use HPACK's never-indexed literal representation.
458+
*
459+
* <p>This method is equivalent to calling {@link #neverIndexMetadataKey} for each key in {@code
460+
* keys}. Duplicate normalized key names are ignored.
461+
*/
462+
@CanIgnoreReturnValue
463+
public NettyChannelBuilder neverIndexMetadataKeys(
464+
Collection<? extends Metadata.Key<?>> keys) {
465+
for (Metadata.Key<?> key : checkNotNull(keys, "keys")) {
466+
neverIndexMetadataKey(key);
467+
}
468+
return this;
469+
}
470+
437471
/**
438472
* Sets the maximum size of header list allowed to be received. This is cumulative size of the
439473
* headers with some overhead, as defined for
@@ -626,6 +660,7 @@ ClientTransportFactory buildTransportFactory() {
626660
eventLoopGroupPool,
627661
autoFlowControl,
628662
flowControlWindow,
663+
neverIndexedMetadataKeys,
629664
maxInboundMessageSize,
630665
maxHeaderListSize,
631666
softLimitHeaderListSize,
@@ -769,6 +804,7 @@ private static final class NettyTransportFactory implements ClientTransportFacto
769804
private final EventLoopGroup group;
770805
private final boolean autoFlowControl;
771806
private final int flowControlWindow;
807+
private final Set<AsciiString> neverIndexedMetadataKeys;
772808
private final int maxMessageSize;
773809
private final int maxHeaderListSize;
774810
private final int softLimitHeaderListSize;
@@ -790,6 +826,7 @@ private static final class NettyTransportFactory implements ClientTransportFacto
790826
ObjectPool<? extends EventLoopGroup> groupPool,
791827
boolean autoFlowControl,
792828
int flowControlWindow,
829+
Set<AsciiString> neverIndexedMetadataKeys,
793830
int maxMessageSize,
794831
int maxHeaderListSize,
795832
int softLimitHeaderListSize,
@@ -807,6 +844,8 @@ private static final class NettyTransportFactory implements ClientTransportFacto
807844
this.group = groupPool.getObject();
808845
this.autoFlowControl = autoFlowControl;
809846
this.flowControlWindow = flowControlWindow;
847+
this.neverIndexedMetadataKeys = Collections.unmodifiableSet(
848+
new HashSet<>(checkNotNull(neverIndexedMetadataKeys, "neverIndexedMetadataKeys")));
810849
this.maxMessageSize = maxMessageSize;
811850
this.maxHeaderListSize = maxHeaderListSize;
812851
this.softLimitHeaderListSize = softLimitHeaderListSize;
@@ -856,6 +895,7 @@ public void run() {
856895
localNegotiator,
857896
autoFlowControl,
858897
flowControlWindow,
898+
neverIndexedMetadataKeys,
859899
maxMessageSize,
860900
maxHeaderListSize,
861901
softLimitHeaderListSize,
@@ -895,6 +935,7 @@ public SwapChannelCredentialsResult swapChannelCredentials(ChannelCredentials ch
895935
groupPool,
896936
autoFlowControl,
897937
flowControlWindow,
938+
neverIndexedMetadataKeys,
898939
maxMessageSize,
899940
maxHeaderListSize,
900941
softLimitHeaderListSize,

netty/src/main/java/io/grpc/netty/NettyClientHandler.java

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
import io.netty.handler.codec.http2.Http2Headers;
7777
import io.netty.handler.codec.http2.Http2HeadersDecoder;
7878
import io.netty.handler.codec.http2.Http2HeadersEncoder;
79+
import io.netty.handler.codec.http2.Http2HeadersEncoder.SensitivityDetector;
7980
import io.netty.handler.codec.http2.Http2InboundFrameLogger;
8081
import io.netty.handler.codec.http2.Http2OutboundFrameLogger;
8182
import io.netty.handler.codec.http2.Http2Settings;
@@ -84,12 +85,14 @@
8485
import io.netty.handler.codec.http2.StreamBufferingEncoder;
8586
import io.netty.handler.codec.http2.UniformStreamByteDistributor;
8687
import io.netty.handler.logging.LogLevel;
88+
import io.netty.util.AsciiString;
8789
import io.perfmark.PerfMark;
8890
import io.perfmark.Tag;
8991
import io.perfmark.TaskCloseable;
9092
import java.nio.channels.ClosedChannelException;
9193
import java.util.LinkedHashMap;
9294
import java.util.Map;
95+
import java.util.Set;
9396
import java.util.concurrent.Executor;
9497
import java.util.logging.Level;
9598
import java.util.logging.Logger;
@@ -158,6 +161,7 @@ static NettyClientHandler newHandler(
158161
@Nullable KeepAliveManager keepAliveManager,
159162
boolean autoFlowControl,
160163
int flowControlWindow,
164+
Set<AsciiString> neverIndexedMetadataKeys,
161165
int maxHeaderListSize,
162166
int softLimitHeaderListSize,
163167
Supplier<Stopwatch> stopwatchFactory,
@@ -172,7 +176,7 @@ static NettyClientHandler newHandler(
172176
Http2HeadersDecoder headersDecoder = new GrpcHttp2ClientHeadersDecoder(maxHeaderListSize);
173177
Http2FrameReader frameReader = new DefaultHttp2FrameReader(headersDecoder);
174178
Http2HeadersEncoder encoder = new DefaultHttp2HeadersEncoder(
175-
Http2HeadersEncoder.NEVER_SENSITIVE, false, 16, Integer.MAX_VALUE);
179+
sensitivityDetector(neverIndexedMetadataKeys), false, 16, Integer.MAX_VALUE);
176180
Http2FrameWriter frameWriter = new DefaultHttp2FrameWriter(encoder);
177181
Http2Connection connection = new DefaultHttp2Connection(false);
178182
UniformStreamByteDistributor dist = new UniformStreamByteDistributor(connection);
@@ -278,6 +282,20 @@ static NettyClientHandler newHandler(
278282
metricRecorder);
279283
}
280284

285+
@VisibleForTesting
286+
static SensitivityDetector sensitivityDetector(
287+
final Set<AsciiString> neverIndexedMetadataKeys) {
288+
if (neverIndexedMetadataKeys.isEmpty()) {
289+
return Http2HeadersEncoder.NEVER_SENSITIVE;
290+
}
291+
return new SensitivityDetector() {
292+
@Override
293+
public boolean isSensitive(CharSequence name, CharSequence value) {
294+
return neverIndexedMetadataKeys.contains(AsciiString.of(name));
295+
}
296+
};
297+
}
298+
281299
private NettyClientHandler(
282300
Http2ConnectionDecoder decoder,
283301
Http2ConnectionEncoder encoder,

netty/src/main/java/io/grpc/netty/NettyClientTransport.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
import java.net.SocketAddress;
6565
import java.nio.channels.ClosedChannelException;
6666
import java.util.Map;
67+
import java.util.Set;
6768
import java.util.concurrent.Executor;
6869
import java.util.concurrent.TimeUnit;
6970
import javax.annotation.Nullable;
@@ -85,6 +86,7 @@ class NettyClientTransport implements ConnectionClientTransport,
8586
private final AsciiString userAgent;
8687
private final boolean autoFlowControl;
8788
private final int flowControlWindow;
89+
private final Set<AsciiString> neverIndexedMetadataKeys;
8890
private final int maxMessageSize;
8991
private final int maxHeaderListSize;
9092
private final int softLimitHeaderListSize;
@@ -120,6 +122,7 @@ class NettyClientTransport implements ConnectionClientTransport,
120122
ProtocolNegotiator negotiator,
121123
boolean autoFlowControl,
122124
int flowControlWindow,
125+
Set<AsciiString> neverIndexedMetadataKeys,
123126
int maxMessageSize,
124127
int maxHeaderListSize,
125128
int softLimitHeaderListSize,
@@ -145,6 +148,8 @@ class NettyClientTransport implements ConnectionClientTransport,
145148
this.channelOptions = Preconditions.checkNotNull(channelOptions, "channelOptions");
146149
this.autoFlowControl = autoFlowControl;
147150
this.flowControlWindow = flowControlWindow;
151+
this.neverIndexedMetadataKeys =
152+
Preconditions.checkNotNull(neverIndexedMetadataKeys, "neverIndexedMetadataKeys");
148153
this.maxMessageSize = maxMessageSize;
149154
this.maxHeaderListSize = maxHeaderListSize;
150155
this.softLimitHeaderListSize = softLimitHeaderListSize;
@@ -247,6 +252,7 @@ public Runnable start(Listener transportListener) {
247252
keepAliveManager,
248253
autoFlowControl,
249254
flowControlWindow,
255+
neverIndexedMetadataKeys,
250256
maxHeaderListSize,
251257
softLimitHeaderListSize,
252258
GrpcUtil.STOPWATCH_SUPPLIER,

netty/src/test/java/io/grpc/netty/NettyChannelBuilderTest.java

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import io.grpc.ChannelCredentials;
2727
import io.grpc.InsecureChannelCredentials;
2828
import io.grpc.ManagedChannel;
29+
import io.grpc.Metadata;
2930
import io.grpc.internal.ClientTransportFactory;
3031
import io.grpc.internal.ClientTransportFactory.SwapChannelCredentialsResult;
3132
import io.grpc.netty.NettyTestUtil.TrackingObjectPoolForTest;
@@ -38,6 +39,8 @@
3839
import io.netty.handler.ssl.SslContext;
3940
import java.net.InetSocketAddress;
4041
import java.net.SocketAddress;
42+
import java.util.Arrays;
43+
import java.util.Collections;
4144
import java.util.concurrent.TimeUnit;
4245
import javax.net.ssl.SSLException;
4346
import org.junit.Test;
@@ -49,6 +52,66 @@ public class NettyChannelBuilderTest {
4952

5053
private final SslContext noSslContext = null;
5154

55+
@Test
56+
public void neverIndexMetadataKeyIsFluentAndAdditive() {
57+
NettyChannelBuilder builder = NettyChannelBuilder.forTarget("foo");
58+
Metadata.Key<String> key =
59+
Metadata.Key.of("X-High-Cardinality", Metadata.ASCII_STRING_MARSHALLER);
60+
Metadata.Key<String> sameNormalizedKey =
61+
Metadata.Key.of("x-high-cardinality", Metadata.ASCII_STRING_MARSHALLER);
62+
63+
assertThat(builder.neverIndexMetadataKey(key)).isSameInstanceAs(builder);
64+
assertThat(builder.neverIndexMetadataKey(sameNormalizedKey)).isSameInstanceAs(builder);
65+
}
66+
67+
@Test
68+
public void neverIndexMetadataKeyRejectsNull() {
69+
NettyChannelBuilder builder = NettyChannelBuilder.forTarget("foo");
70+
71+
NullPointerException exception =
72+
assertThrows(NullPointerException.class, () -> builder.neverIndexMetadataKey(null));
73+
74+
assertThat(exception).hasMessageThat().isEqualTo("key");
75+
}
76+
77+
@Test
78+
public void neverIndexMetadataKeysIsFluentAdditiveAndIgnoresDuplicates() {
79+
NettyChannelBuilder builder = NettyChannelBuilder.forTarget("foo");
80+
Metadata.Key<String> stringKey =
81+
Metadata.Key.of("x-high-cardinality", Metadata.ASCII_STRING_MARSHALLER);
82+
Metadata.Key<String> duplicateStringKey =
83+
Metadata.Key.of("X-High-Cardinality", Metadata.ASCII_STRING_MARSHALLER);
84+
Metadata.Key<byte[]> binaryKey =
85+
Metadata.Key.of("trace-bin", Metadata.BINARY_BYTE_MARSHALLER);
86+
87+
assertThat(builder.neverIndexMetadataKey(stringKey)).isSameInstanceAs(builder);
88+
assertThat(builder.neverIndexMetadataKeys(
89+
Arrays.asList(stringKey, duplicateStringKey, binaryKey)))
90+
.isSameInstanceAs(builder);
91+
}
92+
93+
@Test
94+
public void neverIndexMetadataKeysRejectsNullCollection() {
95+
NettyChannelBuilder builder = NettyChannelBuilder.forTarget("foo");
96+
97+
NullPointerException exception =
98+
assertThrows(NullPointerException.class, () -> builder.neverIndexMetadataKeys(null));
99+
100+
assertThat(exception).hasMessageThat().isEqualTo("keys");
101+
}
102+
103+
@Test
104+
public void neverIndexMetadataKeysRejectsNullElement() {
105+
NettyChannelBuilder builder = NettyChannelBuilder.forTarget("foo");
106+
107+
NullPointerException exception = assertThrows(
108+
NullPointerException.class,
109+
() -> builder.neverIndexMetadataKeys(
110+
Collections.<Metadata.Key<?>>singletonList(null)));
111+
112+
assertThat(exception).hasMessageThat().isEqualTo("key");
113+
}
114+
52115
private void shutdown(ManagedChannel mc) throws Exception {
53116
mc.shutdownNow();
54117
assertTrue(mc.awaitTermination(1, TimeUnit.SECONDS));
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Copyright 2026 The gRPC Authors
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+
17+
package io.grpc.netty;
18+
19+
import static com.google.common.truth.Truth.assertThat;
20+
21+
import io.netty.buffer.ByteBuf;
22+
import io.netty.buffer.Unpooled;
23+
import io.netty.handler.codec.http2.DefaultHttp2Headers;
24+
import io.netty.handler.codec.http2.DefaultHttp2HeadersDecoder;
25+
import io.netty.handler.codec.http2.DefaultHttp2HeadersEncoder;
26+
import io.netty.handler.codec.http2.Http2Headers;
27+
import io.netty.handler.codec.http2.Http2HeadersEncoder;
28+
import io.netty.util.AsciiString;
29+
import java.util.Collections;
30+
import org.junit.Test;
31+
import org.junit.runner.RunWith;
32+
import org.junit.runners.JUnit4;
33+
34+
@RunWith(JUnit4.class)
35+
public class NettyClientHandlerSensitivityDetectorTest {
36+
private static final AsciiString CUSTOM_NAME = AsciiString.cached("custom-key");
37+
private static final AsciiString CUSTOM_VALUE = AsciiString.cached("custom-value");
38+
39+
@Test
40+
public void emptyConfigurationUsesDefaultPolicy() {
41+
assertThat(NettyClientHandler.sensitivityDetector(Collections.<AsciiString>emptySet()))
42+
.isSameInstanceAs(Http2HeadersEncoder.NEVER_SENSITIVE);
43+
}
44+
45+
@Test
46+
public void configuredHeaderIsNeverIndexed() throws Exception {
47+
DefaultHttp2HeadersEncoder encoder = new DefaultHttp2HeadersEncoder(
48+
NettyClientHandler.sensitivityDetector(Collections.singleton(CUSTOM_NAME)),
49+
false,
50+
16,
51+
Integer.MAX_VALUE);
52+
DefaultHttp2HeadersDecoder decoder = new DefaultHttp2HeadersDecoder();
53+
ByteBuf first = Unpooled.buffer();
54+
ByteBuf second = Unpooled.buffer();
55+
try {
56+
Http2Headers headers = new DefaultHttp2Headers().add(CUSTOM_NAME, CUSTOM_VALUE);
57+
58+
encoder.encodeHeaders(1, headers, first);
59+
encoder.encodeHeaders(3, headers, second);
60+
61+
assertThat(first.getUnsignedByte(first.readerIndex()) & 0xF0).isEqualTo(0x10);
62+
assertThat(second.getUnsignedByte(second.readerIndex()) & 0xF0).isEqualTo(0x10);
63+
assertThat(decoder.decodeHeaders(1, first).get(CUSTOM_NAME).toString())
64+
.isEqualTo(CUSTOM_VALUE.toString());
65+
assertThat(decoder.decodeHeaders(3, second).get(CUSTOM_NAME).toString())
66+
.isEqualTo(CUSTOM_VALUE.toString());
67+
} finally {
68+
first.release();
69+
second.release();
70+
encoder.close();
71+
}
72+
}
73+
}

0 commit comments

Comments
 (0)