From 6423ca9ffe76e5d09237badf51d79431d7f6ae1d Mon Sep 17 00:00:00 2001 From: Peter Marsh Date: Fri, 7 Aug 2026 23:10:02 +0200 Subject: [PATCH] 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). --- .../io/grpc/netty/NettyChannelBuilder.java | 41 ++++++ .../io/grpc/netty/NettyClientHandler.java | 20 ++- .../io/grpc/netty/NettyClientTransport.java | 6 + .../grpc/netty/NettyChannelBuilderTest.java | 63 +++++++++ ...yClientHandlerSensitivityDetectorTest.java | 73 +++++++++++ .../grpc/netty/NettyClientTransportTest.java | 3 + .../netty/NeverIndexMetadataInteropTest.java | 122 ++++++++++++++++++ 7 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 netty/src/test/java/io/grpc/netty/NettyClientHandlerSensitivityDetectorTest.java create mode 100644 netty/src/test/java/io/grpc/netty/NeverIndexMetadataInteropTest.java diff --git a/netty/src/main/java/io/grpc/netty/NettyChannelBuilder.java b/netty/src/main/java/io/grpc/netty/NettyChannelBuilder.java index 8ad67f8f14e..dc2e771f16c 100644 --- a/netty/src/main/java/io/grpc/netty/NettyChannelBuilder.java +++ b/netty/src/main/java/io/grpc/netty/NettyChannelBuilder.java @@ -38,6 +38,7 @@ import io.grpc.HttpConnectProxiedSocketAddress; import io.grpc.Internal; import io.grpc.ManagedChannelBuilder; +import io.grpc.Metadata; import io.grpc.NameResolverProvider; import io.grpc.NameResolverRegistry; import io.grpc.internal.AtomicBackoff; @@ -60,12 +61,15 @@ import io.netty.channel.ReflectiveChannelFactory; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.handler.ssl.SslContext; +import io.netty.util.AsciiString; import java.net.InetSocketAddress; import java.net.SocketAddress; import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; +import java.util.Set; import java.util.concurrent.Executor; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -105,6 +109,7 @@ public final class NettyChannelBuilder extends ForwardingChannelBuilder2 eventLoopGroupPool = DEFAULT_EVENT_LOOP_GROUP_POOL; private boolean autoFlowControl = DEFAULT_AUTO_FLOW_CONTROL; private int flowControlWindow = DEFAULT_FLOW_CONTROL_WINDOW; + private final Set neverIndexedMetadataKeys = new HashSet<>(); private int maxHeaderListSize = GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE; private int softLimitHeaderListSize = GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE; private int maxInboundMessageSize = GrpcUtil.DEFAULT_MAX_MESSAGE_SIZE; @@ -434,6 +439,35 @@ public NettyChannelBuilder flowControlWindow(int flowControlWindow) { return this; } + /** + * Configures an outbound metadata key to use HPACK's never-indexed literal representation. + * + *

All values associated with the key's normalized name will be sent as literals and will not + * be added to the peer's HPACK dynamic table. This method is additive and may be called multiple + * times. Configuring the same normalized key name more than once has no additional effect. By + * default, no metadata keys are configured as never indexed. + */ + @CanIgnoreReturnValue + public NettyChannelBuilder neverIndexMetadataKey(Metadata.Key key) { + neverIndexedMetadataKeys.add(AsciiString.of(checkNotNull(key, "key").name())); + return this; + } + + /** + * Configures outbound metadata keys to use HPACK's never-indexed literal representation. + * + *

This method is equivalent to calling {@link #neverIndexMetadataKey} for each key in {@code + * keys}. Duplicate normalized key names are ignored. + */ + @CanIgnoreReturnValue + public NettyChannelBuilder neverIndexMetadataKeys( + Collection> keys) { + for (Metadata.Key key : checkNotNull(keys, "keys")) { + neverIndexMetadataKey(key); + } + return this; + } + /** * Sets the maximum size of header list allowed to be received. This is cumulative size of the * headers with some overhead, as defined for @@ -626,6 +660,7 @@ ClientTransportFactory buildTransportFactory() { eventLoopGroupPool, autoFlowControl, flowControlWindow, + neverIndexedMetadataKeys, maxInboundMessageSize, maxHeaderListSize, softLimitHeaderListSize, @@ -769,6 +804,7 @@ private static final class NettyTransportFactory implements ClientTransportFacto private final EventLoopGroup group; private final boolean autoFlowControl; private final int flowControlWindow; + private final Set neverIndexedMetadataKeys; private final int maxMessageSize; private final int maxHeaderListSize; private final int softLimitHeaderListSize; @@ -790,6 +826,7 @@ private static final class NettyTransportFactory implements ClientTransportFacto ObjectPool groupPool, boolean autoFlowControl, int flowControlWindow, + Set neverIndexedMetadataKeys, int maxMessageSize, int maxHeaderListSize, int softLimitHeaderListSize, @@ -807,6 +844,8 @@ private static final class NettyTransportFactory implements ClientTransportFacto this.group = groupPool.getObject(); this.autoFlowControl = autoFlowControl; this.flowControlWindow = flowControlWindow; + this.neverIndexedMetadataKeys = Collections.unmodifiableSet( + new HashSet<>(checkNotNull(neverIndexedMetadataKeys, "neverIndexedMetadataKeys"))); this.maxMessageSize = maxMessageSize; this.maxHeaderListSize = maxHeaderListSize; this.softLimitHeaderListSize = softLimitHeaderListSize; @@ -856,6 +895,7 @@ public void run() { localNegotiator, autoFlowControl, flowControlWindow, + neverIndexedMetadataKeys, maxMessageSize, maxHeaderListSize, softLimitHeaderListSize, @@ -895,6 +935,7 @@ public SwapChannelCredentialsResult swapChannelCredentials(ChannelCredentials ch groupPool, autoFlowControl, flowControlWindow, + neverIndexedMetadataKeys, maxMessageSize, maxHeaderListSize, softLimitHeaderListSize, diff --git a/netty/src/main/java/io/grpc/netty/NettyClientHandler.java b/netty/src/main/java/io/grpc/netty/NettyClientHandler.java index 14a1d7535ad..4ccc8747bb5 100644 --- a/netty/src/main/java/io/grpc/netty/NettyClientHandler.java +++ b/netty/src/main/java/io/grpc/netty/NettyClientHandler.java @@ -76,6 +76,7 @@ import io.netty.handler.codec.http2.Http2Headers; import io.netty.handler.codec.http2.Http2HeadersDecoder; import io.netty.handler.codec.http2.Http2HeadersEncoder; +import io.netty.handler.codec.http2.Http2HeadersEncoder.SensitivityDetector; import io.netty.handler.codec.http2.Http2InboundFrameLogger; import io.netty.handler.codec.http2.Http2OutboundFrameLogger; import io.netty.handler.codec.http2.Http2Settings; @@ -84,12 +85,14 @@ import io.netty.handler.codec.http2.StreamBufferingEncoder; import io.netty.handler.codec.http2.UniformStreamByteDistributor; import io.netty.handler.logging.LogLevel; +import io.netty.util.AsciiString; import io.perfmark.PerfMark; import io.perfmark.Tag; import io.perfmark.TaskCloseable; import java.nio.channels.ClosedChannelException; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Set; import java.util.concurrent.Executor; import java.util.logging.Level; import java.util.logging.Logger; @@ -158,6 +161,7 @@ static NettyClientHandler newHandler( @Nullable KeepAliveManager keepAliveManager, boolean autoFlowControl, int flowControlWindow, + Set neverIndexedMetadataKeys, int maxHeaderListSize, int softLimitHeaderListSize, Supplier stopwatchFactory, @@ -172,7 +176,7 @@ static NettyClientHandler newHandler( Http2HeadersDecoder headersDecoder = new GrpcHttp2ClientHeadersDecoder(maxHeaderListSize); Http2FrameReader frameReader = new DefaultHttp2FrameReader(headersDecoder); Http2HeadersEncoder encoder = new DefaultHttp2HeadersEncoder( - Http2HeadersEncoder.NEVER_SENSITIVE, false, 16, Integer.MAX_VALUE); + sensitivityDetector(neverIndexedMetadataKeys), false, 16, Integer.MAX_VALUE); Http2FrameWriter frameWriter = new DefaultHttp2FrameWriter(encoder); Http2Connection connection = new DefaultHttp2Connection(false); UniformStreamByteDistributor dist = new UniformStreamByteDistributor(connection); @@ -278,6 +282,20 @@ static NettyClientHandler newHandler( metricRecorder); } + @VisibleForTesting + static SensitivityDetector sensitivityDetector( + final Set neverIndexedMetadataKeys) { + if (neverIndexedMetadataKeys.isEmpty()) { + return Http2HeadersEncoder.NEVER_SENSITIVE; + } + return new SensitivityDetector() { + @Override + public boolean isSensitive(CharSequence name, CharSequence value) { + return neverIndexedMetadataKeys.contains(AsciiString.of(name)); + } + }; + } + private NettyClientHandler( Http2ConnectionDecoder decoder, Http2ConnectionEncoder encoder, diff --git a/netty/src/main/java/io/grpc/netty/NettyClientTransport.java b/netty/src/main/java/io/grpc/netty/NettyClientTransport.java index 6585df42df3..2d7873f0d7b 100644 --- a/netty/src/main/java/io/grpc/netty/NettyClientTransport.java +++ b/netty/src/main/java/io/grpc/netty/NettyClientTransport.java @@ -64,6 +64,7 @@ import java.net.SocketAddress; import java.nio.channels.ClosedChannelException; import java.util.Map; +import java.util.Set; import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; import javax.annotation.Nullable; @@ -85,6 +86,7 @@ class NettyClientTransport implements ConnectionClientTransport, private final AsciiString userAgent; private final boolean autoFlowControl; private final int flowControlWindow; + private final Set neverIndexedMetadataKeys; private final int maxMessageSize; private final int maxHeaderListSize; private final int softLimitHeaderListSize; @@ -120,6 +122,7 @@ class NettyClientTransport implements ConnectionClientTransport, ProtocolNegotiator negotiator, boolean autoFlowControl, int flowControlWindow, + Set neverIndexedMetadataKeys, int maxMessageSize, int maxHeaderListSize, int softLimitHeaderListSize, @@ -145,6 +148,8 @@ class NettyClientTransport implements ConnectionClientTransport, this.channelOptions = Preconditions.checkNotNull(channelOptions, "channelOptions"); this.autoFlowControl = autoFlowControl; this.flowControlWindow = flowControlWindow; + this.neverIndexedMetadataKeys = + Preconditions.checkNotNull(neverIndexedMetadataKeys, "neverIndexedMetadataKeys"); this.maxMessageSize = maxMessageSize; this.maxHeaderListSize = maxHeaderListSize; this.softLimitHeaderListSize = softLimitHeaderListSize; @@ -247,6 +252,7 @@ public Runnable start(Listener transportListener) { keepAliveManager, autoFlowControl, flowControlWindow, + neverIndexedMetadataKeys, maxHeaderListSize, softLimitHeaderListSize, GrpcUtil.STOPWATCH_SUPPLIER, diff --git a/netty/src/test/java/io/grpc/netty/NettyChannelBuilderTest.java b/netty/src/test/java/io/grpc/netty/NettyChannelBuilderTest.java index 95d54d13b82..1214c2a6f42 100644 --- a/netty/src/test/java/io/grpc/netty/NettyChannelBuilderTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyChannelBuilderTest.java @@ -26,6 +26,7 @@ import io.grpc.ChannelCredentials; import io.grpc.InsecureChannelCredentials; import io.grpc.ManagedChannel; +import io.grpc.Metadata; import io.grpc.internal.ClientTransportFactory; import io.grpc.internal.ClientTransportFactory.SwapChannelCredentialsResult; import io.grpc.netty.NettyTestUtil.TrackingObjectPoolForTest; @@ -38,6 +39,8 @@ import io.netty.handler.ssl.SslContext; import java.net.InetSocketAddress; import java.net.SocketAddress; +import java.util.Arrays; +import java.util.Collections; import java.util.concurrent.TimeUnit; import javax.net.ssl.SSLException; import org.junit.Test; @@ -49,6 +52,66 @@ public class NettyChannelBuilderTest { private final SslContext noSslContext = null; + @Test + public void neverIndexMetadataKeyIsFluentAndAdditive() { + NettyChannelBuilder builder = NettyChannelBuilder.forTarget("foo"); + Metadata.Key key = + Metadata.Key.of("X-High-Cardinality", Metadata.ASCII_STRING_MARSHALLER); + Metadata.Key sameNormalizedKey = + Metadata.Key.of("x-high-cardinality", Metadata.ASCII_STRING_MARSHALLER); + + assertThat(builder.neverIndexMetadataKey(key)).isSameInstanceAs(builder); + assertThat(builder.neverIndexMetadataKey(sameNormalizedKey)).isSameInstanceAs(builder); + } + + @Test + public void neverIndexMetadataKeyRejectsNull() { + NettyChannelBuilder builder = NettyChannelBuilder.forTarget("foo"); + + NullPointerException exception = + assertThrows(NullPointerException.class, () -> builder.neverIndexMetadataKey(null)); + + assertThat(exception).hasMessageThat().isEqualTo("key"); + } + + @Test + public void neverIndexMetadataKeysIsFluentAdditiveAndIgnoresDuplicates() { + NettyChannelBuilder builder = NettyChannelBuilder.forTarget("foo"); + Metadata.Key stringKey = + Metadata.Key.of("x-high-cardinality", Metadata.ASCII_STRING_MARSHALLER); + Metadata.Key duplicateStringKey = + Metadata.Key.of("X-High-Cardinality", Metadata.ASCII_STRING_MARSHALLER); + Metadata.Key binaryKey = + Metadata.Key.of("trace-bin", Metadata.BINARY_BYTE_MARSHALLER); + + assertThat(builder.neverIndexMetadataKey(stringKey)).isSameInstanceAs(builder); + assertThat(builder.neverIndexMetadataKeys( + Arrays.asList(stringKey, duplicateStringKey, binaryKey))) + .isSameInstanceAs(builder); + } + + @Test + public void neverIndexMetadataKeysRejectsNullCollection() { + NettyChannelBuilder builder = NettyChannelBuilder.forTarget("foo"); + + NullPointerException exception = + assertThrows(NullPointerException.class, () -> builder.neverIndexMetadataKeys(null)); + + assertThat(exception).hasMessageThat().isEqualTo("keys"); + } + + @Test + public void neverIndexMetadataKeysRejectsNullElement() { + NettyChannelBuilder builder = NettyChannelBuilder.forTarget("foo"); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> builder.neverIndexMetadataKeys( + Collections.>singletonList(null))); + + assertThat(exception).hasMessageThat().isEqualTo("key"); + } + private void shutdown(ManagedChannel mc) throws Exception { mc.shutdownNow(); assertTrue(mc.awaitTermination(1, TimeUnit.SECONDS)); diff --git a/netty/src/test/java/io/grpc/netty/NettyClientHandlerSensitivityDetectorTest.java b/netty/src/test/java/io/grpc/netty/NettyClientHandlerSensitivityDetectorTest.java new file mode 100644 index 00000000000..96561fef3e0 --- /dev/null +++ b/netty/src/test/java/io/grpc/netty/NettyClientHandlerSensitivityDetectorTest.java @@ -0,0 +1,73 @@ +/* + * Copyright 2026 The gRPC Authors + * + * 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 io.grpc.netty; + +import static com.google.common.truth.Truth.assertThat; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.handler.codec.http2.DefaultHttp2Headers; +import io.netty.handler.codec.http2.DefaultHttp2HeadersDecoder; +import io.netty.handler.codec.http2.DefaultHttp2HeadersEncoder; +import io.netty.handler.codec.http2.Http2Headers; +import io.netty.handler.codec.http2.Http2HeadersEncoder; +import io.netty.util.AsciiString; +import java.util.Collections; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class NettyClientHandlerSensitivityDetectorTest { + private static final AsciiString CUSTOM_NAME = AsciiString.cached("custom-key"); + private static final AsciiString CUSTOM_VALUE = AsciiString.cached("custom-value"); + + @Test + public void emptyConfigurationUsesDefaultPolicy() { + assertThat(NettyClientHandler.sensitivityDetector(Collections.emptySet())) + .isSameInstanceAs(Http2HeadersEncoder.NEVER_SENSITIVE); + } + + @Test + public void configuredHeaderIsNeverIndexed() throws Exception { + DefaultHttp2HeadersEncoder encoder = new DefaultHttp2HeadersEncoder( + NettyClientHandler.sensitivityDetector(Collections.singleton(CUSTOM_NAME)), + false, + 16, + Integer.MAX_VALUE); + DefaultHttp2HeadersDecoder decoder = new DefaultHttp2HeadersDecoder(); + ByteBuf first = Unpooled.buffer(); + ByteBuf second = Unpooled.buffer(); + try { + Http2Headers headers = new DefaultHttp2Headers().add(CUSTOM_NAME, CUSTOM_VALUE); + + encoder.encodeHeaders(1, headers, first); + encoder.encodeHeaders(3, headers, second); + + assertThat(first.getUnsignedByte(first.readerIndex()) & 0xF0).isEqualTo(0x10); + assertThat(second.getUnsignedByte(second.readerIndex()) & 0xF0).isEqualTo(0x10); + assertThat(decoder.decodeHeaders(1, first).get(CUSTOM_NAME).toString()) + .isEqualTo(CUSTOM_VALUE.toString()); + assertThat(decoder.decodeHeaders(3, second).get(CUSTOM_NAME).toString()) + .isEqualTo(CUSTOM_VALUE.toString()); + } finally { + first.release(); + second.release(); + encoder.close(); + } + } +} diff --git a/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java b/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java index ef8d2e5efda..ff2598fb5bf 100644 --- a/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java @@ -237,6 +237,7 @@ public void setSoLingerChannelOption() throws IOException, GeneralSecurityExcept newNegotiator(), false, DEFAULT_WINDOW_SIZE, + Collections.emptySet(), DEFAULT_MAX_MESSAGE_SIZE, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, @@ -513,6 +514,7 @@ public void failingToConstructChannelShouldFailGracefully() throws Exception { newNegotiator(), false, DEFAULT_WINDOW_SIZE, + Collections.emptySet(), DEFAULT_MAX_MESSAGE_SIZE, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, @@ -1147,6 +1149,7 @@ private NettyClientTransport newTransport(ProtocolNegotiator negotiator, int max negotiator, false, DEFAULT_WINDOW_SIZE, + Collections.emptySet(), maxMsgSize, maxHeaderListSize, maxHeaderListSize, diff --git a/netty/src/test/java/io/grpc/netty/NeverIndexMetadataInteropTest.java b/netty/src/test/java/io/grpc/netty/NeverIndexMetadataInteropTest.java new file mode 100644 index 00000000000..c90ec216e9b --- /dev/null +++ b/netty/src/test/java/io/grpc/netty/NeverIndexMetadataInteropTest.java @@ -0,0 +1,122 @@ +/* + * Copyright 2026 The gRPC Authors + * + * 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 io.grpc.netty; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import io.grpc.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.Server; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.ServerInterceptors; +import io.grpc.stub.MetadataUtils; +import io.grpc.stub.StreamObserver; +import io.grpc.testing.protobuf.SimpleRequest; +import io.grpc.testing.protobuf.SimpleResponse; +import io.grpc.testing.protobuf.SimpleServiceGrpc; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.After; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class NeverIndexMetadataInteropTest { + private static final int RPC_COUNT = 10; + private static final Metadata.Key NEVER_INDEXED_REQUEST_METADATA_KEY = + Metadata.Key.of("x-hpack-never-indexed-request", Metadata.ASCII_STRING_MARSHALLER); + + private Server server; + private ManagedChannel channel; + + @After + public void tearDown() throws Exception { + if (channel != null) { + channel.shutdownNow(); + channel.awaitTermination(5, TimeUnit.SECONDS); + } + if (server != null) { + server.shutdownNow(); + server.awaitTermination(5, TimeUnit.SECONDS); + } + } + + @Test + public void neverIndexedRequestMetadataInteroperates() throws Exception { + AtomicReference requestMetadataCapture = new AtomicReference<>(); + server = NettyServerBuilder.forPort(0) + .addService( + ServerInterceptors.intercept( + new SimpleServiceImpl(), + new CapturingServerInterceptor(requestMetadataCapture))) + .build() + .start(); + + channel = NettyChannelBuilder.forAddress("localhost", server.getPort()) + .usePlaintext() + .neverIndexMetadataKey(NEVER_INDEXED_REQUEST_METADATA_KEY) + .build(); + + SimpleServiceGrpc.SimpleServiceBlockingStub baseStub = + SimpleServiceGrpc.newBlockingStub(channel); + for (int i = 0; i < RPC_COUNT; i++) { + String neverIndexedValue = "high-cardinality-value-" + i; + Metadata requestMetadata = new Metadata(); + requestMetadata.put(NEVER_INDEXED_REQUEST_METADATA_KEY, neverIndexedValue); + + SimpleResponse response = + baseStub.withInterceptors(MetadataUtils.newAttachHeadersInterceptor(requestMetadata)) + .withDeadlineAfter(10, TimeUnit.SECONDS) + .unaryRpc(SimpleRequest.getDefaultInstance()); + + assertEquals(SimpleResponse.getDefaultInstance(), response); + assertNotNull(requestMetadataCapture.get()); + assertEquals( + neverIndexedValue, + requestMetadataCapture.get().get(NEVER_INDEXED_REQUEST_METADATA_KEY)); + } + } + + private static final class CapturingServerInterceptor implements ServerInterceptor { + private final AtomicReference requestMetadataCapture; + + CapturingServerInterceptor(AtomicReference requestMetadataCapture) { + this.requestMetadataCapture = requestMetadataCapture; + } + + @Override + public ServerCall.Listener interceptCall( + ServerCall call, + Metadata headers, + ServerCallHandler next) { + requestMetadataCapture.set(headers); + return next.startCall(call, headers); + } + } + + private static final class SimpleServiceImpl extends SimpleServiceGrpc.SimpleServiceImplBase { + @Override + public void unaryRpc(SimpleRequest request, StreamObserver responseObserver) { + responseObserver.onNext(SimpleResponse.getDefaultInstance()); + responseObserver.onCompleted(); + } + } +}