From b86a5d68d29727fb2bbf64bec507fa2c341d9215 Mon Sep 17 00:00:00 2001 From: Michael McCord <9398541+michaelmccord@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:37:16 -0400 Subject: [PATCH 1/3] Handle Trailers-Only responses without relying on exception handling Avoid expensive TaskCanceledException in streaming calls when the server returns grpc-status in response headers (Trailers-Only). Set HttpResponseTcs before cleanup so MoveNextCore can read the empty stream gracefully. Also handle null HttpResponseMessage.Content on .NET Framework and read grpc-status from response headers in GetResponseStatus as a fallback. --- src/Grpc.Net.Client/Internal/GrpcCall.cs | 24 ++++++++++++------- .../Internal/GrpcProtocolHelpers.cs | 14 +++++++++++ .../Internal/HttpContentClientStreamReader.cs | 6 ++++- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/Grpc.Net.Client/Internal/GrpcCall.cs b/src/Grpc.Net.Client/Internal/GrpcCall.cs index 890b02379..d2d151b33 100644 --- a/src/Grpc.Net.Client/Internal/GrpcCall.cs +++ b/src/Grpc.Net.Client/Internal/GrpcCall.cs @@ -20,6 +20,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Net; using System.Runtime.ExceptionServices; using Grpc.Core; using Grpc.Net.Client.Internal.Http; @@ -307,15 +308,6 @@ private async Task GetResponseHeadersCoreAsync() { var httpResponse = await HttpResponseTask.ConfigureAwait(false); - // Check if the headers have a status. If they do then wait for the overall call task - // to complete before returning headers. This means that if the call failed with a - // a status then it is possible to await response headers and then call GetStatus(). - var grpcStatus = HttpRequestHelpers.GetHeaderValue(httpResponse.Headers, GrpcProtocolConstants.StatusTrailer); - if (grpcStatus != null) - { - await CallTask.ConfigureAwait(false); - } - var metadata = GrpcProtocolHelpers.BuildMetadata(httpResponse.Headers); // https://github.com/grpc/proposal/blob/master/A6-client-retries.md#exposed-retry-metadata @@ -550,6 +542,20 @@ private async Task RunCall(HttpRequestMessage request, TimeSpan? timeout) } else { + if (ClientStreamReader != null + && ClientStreamReader.HttpResponseTcs != null + && status.Value.StatusCode == StatusCode.OK + && HttpResponse != null + && HttpResponse.StatusCode == System.Net.HttpStatusCode.OK) + { + ClientStreamReader.HttpResponseTcs.TrySetResult((HttpResponse, status)); + + // This is a Trailers-Only response with OK status for a streaming call. + // Hand the response to the stream reader so it can process the (empty) + // stream and resolve the final call status. TCS will also be set in Dispose. + status = await CallTask.ConfigureAwait(false); + } + finished = FinishCall(request, diagnosticSourceEnabled, activity, status.Value); FinishResponseAndCleanUp(status.Value); } diff --git a/src/Grpc.Net.Client/Internal/GrpcProtocolHelpers.cs b/src/Grpc.Net.Client/Internal/GrpcProtocolHelpers.cs index 0ce01c4b5..909cb06d4 100644 --- a/src/Grpc.Net.Client/Internal/GrpcProtocolHelpers.cs +++ b/src/Grpc.Net.Client/Internal/GrpcProtocolHelpers.cs @@ -328,6 +328,20 @@ public static Status GetResponseStatus(HttpResponseMessage httpResponse, bool is { if (!TryGetStatusCore(httpResponse.TrailingHeaders(), out status)) { + if (httpResponse.Headers.TryGetValues(GrpcProtocolConstants.StatusTrailer, out var grpcStatus)) + { + // This is a Trailers-Only response: the server included grpc-status in + // the response headers instead of trailers. Per the gRPC spec, this + // happens when the server completes the call without sending a response + // body (e.g. immediate errors, empty server streaming calls). + var grpcMessage = + HttpRequestHelpers.GetHeaderValue( + httpResponse.Headers, GrpcProtocolConstants.MessageTrailer); + + status = new Status((StatusCode)int.Parse(grpcStatus.First(), CultureInfo.InvariantCulture), grpcMessage ?? string.Empty); + return status.Value; + } + var detail = "No grpc-status found on response."; if (isBrowser) { diff --git a/src/Grpc.Net.Client/Internal/HttpContentClientStreamReader.cs b/src/Grpc.Net.Client/Internal/HttpContentClientStreamReader.cs index 907604752..af1ff2248 100644 --- a/src/Grpc.Net.Client/Internal/HttpContentClientStreamReader.cs +++ b/src/Grpc.Net.Client/Internal/HttpContentClientStreamReader.cs @@ -142,7 +142,11 @@ private async Task MoveNextCore(CancellationToken cancellationToken) #if NET5_0_OR_GREATER _responseStream = await _httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); #else - _responseStream = await _httpResponse.Content.ReadAsStreamAsync().ConfigureAwait(false); + // On .NET Framework, HttpResponseMessage.Content can be null when no content + // body was set (e.g. a Trailers-Only response). Use Stream.Null in that case. + _responseStream = _httpResponse.Content != null + ? await _httpResponse.Content.ReadAsStreamAsync().ConfigureAwait(false) + : Stream.Null; #endif } catch (ObjectDisposedException) From cdfb654e18f65f42bf280f40ef5d802f812c3ee3 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 23 Mar 2026 17:04:25 +0800 Subject: [PATCH 2/3] Add trailers-only response unit tests for client streaming and duplex streaming --- .../AsyncClientStreamingCallTests.cs | 53 +++++++++++++++++++ .../AsyncDuplexStreamingCallTests.cs | 42 +++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/test/Grpc.Net.Client.Tests/AsyncClientStreamingCallTests.cs b/test/Grpc.Net.Client.Tests/AsyncClientStreamingCallTests.cs index 8f126b8e8..3cb63f4a1 100644 --- a/test/Grpc.Net.Client.Tests/AsyncClientStreamingCallTests.cs +++ b/test/Grpc.Net.Client.Tests/AsyncClientStreamingCallTests.cs @@ -23,6 +23,7 @@ using Grpc.Net.Client.Internal; using Grpc.Net.Client.Internal.Http; using Grpc.Net.Client.Tests.Infrastructure; +using Grpc.Shared; using Grpc.Tests.Shared; using Microsoft.Extensions.Logging.Testing; using NUnit.Framework; @@ -411,6 +412,58 @@ public override async Task WriteAsync(byte[] buffer, int offset, int count, Canc #endif } + [TestCase(StatusCode.OK, "Detail!")] + [TestCase(StatusCode.Unauthenticated, "")] + public async Task AsyncClientStreamingCall_TrailersOnly_TrailersReturnedWithHeaders(StatusCode statusCode, string statusMessage) + { + // Arrange + HttpResponseMessage? responseMessage = null; + var httpClient = ClientTestHelpers.CreateTestClient(request => + { + responseMessage = ResponseUtils.CreateResponse(HttpStatusCode.OK, new ByteArrayContent(Array.Empty()), grpcStatusCode: null); + responseMessage.Headers.Add(GrpcProtocolConstants.StatusTrailer, statusCode.ToString("D")); + responseMessage.Headers.Add(GrpcProtocolConstants.MessageTrailer, statusMessage); + return Task.FromResult(responseMessage); + }); + var invoker = HttpClientCallInvokerFactory.Create(httpClient); + + // Act + var call = invoker.AsyncClientStreamingCall(); + var headers = await call.ResponseHeadersAsync.DefaultTimeout(); + if (statusCode == StatusCode.OK) + { + // Trailers-only with OK status has no response message to deserialize + var ex = await ExceptionAssert.ThrowsAsync(() => call.ResponseAsync).DefaultTimeout(); + Assert.AreEqual(StatusCode.Internal, ex.Status.StatusCode); + StringAssert.StartsWith("Failed to deserialize response message.", ex.Status.Detail); + } + else + { + var ex = await ExceptionAssert.ThrowsAsync(() => call.ResponseAsync).DefaultTimeout(); + Assert.AreEqual(statusCode, ex.StatusCode); + Assert.AreEqual(statusMessage, ex.Status.Detail); + } + + // Assert + Assert.NotNull(responseMessage); + + Assert.IsFalse(responseMessage!.TrailingHeaders().Any()); // sanity check that there are no trailers + + if (statusCode == StatusCode.OK) + { + Assert.AreEqual(StatusCode.Internal, call.GetStatus().StatusCode); + StringAssert.StartsWith("Failed to deserialize response message.", call.GetStatus().Detail); + } + else + { + Assert.AreEqual(statusCode, call.GetStatus().StatusCode); + Assert.AreEqual(statusMessage, call.GetStatus().Detail); + } + + Assert.AreEqual(0, headers.Count); + Assert.AreEqual(0, call.GetTrailers().Count); + } + [Test] public async Task ClientStreamWriter_CancelledBeforeCallStarts_ThrowsError() { diff --git a/test/Grpc.Net.Client.Tests/AsyncDuplexStreamingCallTests.cs b/test/Grpc.Net.Client.Tests/AsyncDuplexStreamingCallTests.cs index 0dfe9c663..21f88a8a1 100644 --- a/test/Grpc.Net.Client.Tests/AsyncDuplexStreamingCallTests.cs +++ b/test/Grpc.Net.Client.Tests/AsyncDuplexStreamingCallTests.cs @@ -181,6 +181,48 @@ await streamContent.AddDataAndWait(await ClientTestHelpers.GetResponseDataAsync( Assert.IsFalse(await moveNextTask3.DefaultTimeout()); } + [TestCase(StatusCode.OK, "Detail!")] + [TestCase(StatusCode.Unauthenticated, "")] + public async Task AsyncDuplexStreamingCall_TrailersOnly_TrailersReturnedWithHeaders(StatusCode statusCode, string statusMessage) + { + // Arrange + HttpResponseMessage? responseMessage = null; + var httpClient = ClientTestHelpers.CreateTestClient(request => + { + responseMessage = ResponseUtils.CreateResponse(HttpStatusCode.OK, new ByteArrayContent(Array.Empty()), grpcStatusCode: null); + responseMessage.Headers.Add(GrpcProtocolConstants.StatusTrailer, statusCode.ToString("D")); + responseMessage.Headers.Add(GrpcProtocolConstants.MessageTrailer, statusMessage); + return Task.FromResult(responseMessage); + }); + var invoker = HttpClientCallInvokerFactory.Create(httpClient); + + // Act + var call = invoker.AsyncDuplexStreamingCall(); + var headers = await call.ResponseHeadersAsync.DefaultTimeout(); + var moveNextTask = call.ResponseStream.MoveNext(CancellationToken.None); + if (statusCode == StatusCode.OK) + { + Assert.IsFalse(await moveNextTask.DefaultTimeout()); + } + else + { + var ex = await ExceptionAssert.ThrowsAsync(() => moveNextTask).DefaultTimeout(); + Assert.AreEqual(statusCode, ex.StatusCode); + Assert.AreEqual(statusMessage, ex.Status.Detail); + } + + // Assert + Assert.NotNull(responseMessage); + + Assert.IsFalse(responseMessage!.TrailingHeaders().Any()); // sanity check that there are no trailers + + Assert.AreEqual(statusCode, call.GetStatus().StatusCode); + Assert.AreEqual(statusMessage, call.GetStatus().Detail); + + Assert.AreEqual(0, headers.Count); + Assert.AreEqual(0, call.GetTrailers().Count); + } + [Test] public async Task AsyncDuplexStreamingCall_CancellationDisposeRace_Success() { From cfaaa4c10b606493c5af7338a9b7cc071fde4192 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 23 Mar 2026 17:05:41 +0800 Subject: [PATCH 3/3] Update GrpcCall, GrpcProtocolHelpers, and server streaming tests --- src/Grpc.Net.Client/Internal/GrpcCall.cs | 11 ++------ .../Internal/GrpcProtocolHelpers.cs | 20 ++++--------- .../AsyncServerStreamingCallTests.cs | 28 ++++++++++++++----- 3 files changed, 29 insertions(+), 30 deletions(-) diff --git a/src/Grpc.Net.Client/Internal/GrpcCall.cs b/src/Grpc.Net.Client/Internal/GrpcCall.cs index d2d151b33..fae7f9907 100644 --- a/src/Grpc.Net.Client/Internal/GrpcCall.cs +++ b/src/Grpc.Net.Client/Internal/GrpcCall.cs @@ -20,7 +20,6 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; -using System.Net; using System.Runtime.ExceptionServices; using Grpc.Core; using Grpc.Net.Client.Internal.Http; @@ -542,17 +541,13 @@ private async Task RunCall(HttpRequestMessage request, TimeSpan? timeout) } else { - if (ClientStreamReader != null - && ClientStreamReader.HttpResponseTcs != null - && status.Value.StatusCode == StatusCode.OK - && HttpResponse != null - && HttpResponse.StatusCode == System.Net.HttpStatusCode.OK) + if (ClientStreamReader != null && status.Value.StatusCode == StatusCode.OK) { - ClientStreamReader.HttpResponseTcs.TrySetResult((HttpResponse, status)); - // This is a Trailers-Only response with OK status for a streaming call. // Hand the response to the stream reader so it can process the (empty) // stream and resolve the final call status. TCS will also be set in Dispose. + ClientStreamReader.HttpResponseTcs.TrySetResult((HttpResponse, status)); + status = await CallTask.ConfigureAwait(false); } diff --git a/src/Grpc.Net.Client/Internal/GrpcProtocolHelpers.cs b/src/Grpc.Net.Client/Internal/GrpcProtocolHelpers.cs index 909cb06d4..0760c0490 100644 --- a/src/Grpc.Net.Client/Internal/GrpcProtocolHelpers.cs +++ b/src/Grpc.Net.Client/Internal/GrpcProtocolHelpers.cs @@ -326,22 +326,12 @@ public static Status GetResponseStatus(HttpResponseMessage httpResponse, bool is Status? status; try { - if (!TryGetStatusCore(httpResponse.TrailingHeaders(), out status)) + // Scenarios: + // 1. The status will be in the trailers for a response with a message. + // 2. Trailers are in the headers when there is no message. That means we also check the headers for a trailers-only only response. + // 3. No status. This is an error. Return cancelled status. + if (!TryGetStatusCore(httpResponse.TrailingHeaders(), out status) && !TryGetStatusCore(httpResponse.Headers, out status)) { - if (httpResponse.Headers.TryGetValues(GrpcProtocolConstants.StatusTrailer, out var grpcStatus)) - { - // This is a Trailers-Only response: the server included grpc-status in - // the response headers instead of trailers. Per the gRPC spec, this - // happens when the server completes the call without sending a response - // body (e.g. immediate errors, empty server streaming calls). - var grpcMessage = - HttpRequestHelpers.GetHeaderValue( - httpResponse.Headers, GrpcProtocolConstants.MessageTrailer); - - status = new Status((StatusCode)int.Parse(grpcStatus.First(), CultureInfo.InvariantCulture), grpcMessage ?? string.Empty); - return status.Value; - } - var detail = "No grpc-status found on response."; if (isBrowser) { diff --git a/test/Grpc.Net.Client.Tests/AsyncServerStreamingCallTests.cs b/test/Grpc.Net.Client.Tests/AsyncServerStreamingCallTests.cs index 151238300..94dc32f41 100644 --- a/test/Grpc.Net.Client.Tests/AsyncServerStreamingCallTests.cs +++ b/test/Grpc.Net.Client.Tests/AsyncServerStreamingCallTests.cs @@ -335,18 +335,22 @@ public async Task ClientStreamReader_WriteWithInvalidHttpStatus_ErrorThrown() Assert.AreEqual(StatusCode.Unimplemented, ex.StatusCode); Assert.AreEqual("Bad gRPC response. HTTP status code: 404", ex.Status.Detail); + + Assert.AreEqual(StatusCode.Unimplemented, call.GetStatus().StatusCode); + Assert.AreEqual("Bad gRPC response. HTTP status code: 404", call.GetStatus().Detail); } - [Test] - public async Task AsyncServerStreamingCall_TrailersOnly_TrailersReturnedWithHeaders() + [TestCase(StatusCode.OK, "Detail!")] + [TestCase(StatusCode.Unauthenticated, "")] + public async Task AsyncServerStreamingCall_TrailersOnly_TrailersReturnedWithHeaders(StatusCode statusCode, string statusMessage) { // Arrange HttpResponseMessage? responseMessage = null; var httpClient = ClientTestHelpers.CreateTestClient(request => { responseMessage = ResponseUtils.CreateResponse(HttpStatusCode.OK, new ByteArrayContent(Array.Empty()), grpcStatusCode: null); - responseMessage.Headers.Add(GrpcProtocolConstants.StatusTrailer, StatusCode.OK.ToString("D")); - responseMessage.Headers.Add(GrpcProtocolConstants.MessageTrailer, "Detail!"); + responseMessage.Headers.Add(GrpcProtocolConstants.StatusTrailer, statusCode.ToString("D")); + responseMessage.Headers.Add(GrpcProtocolConstants.MessageTrailer, statusMessage); return Task.FromResult(responseMessage); }); var invoker = HttpClientCallInvokerFactory.Create(httpClient); @@ -354,15 +358,25 @@ public async Task AsyncServerStreamingCall_TrailersOnly_TrailersReturnedWithHead // Act var call = invoker.AsyncServerStreamingCall(new HelloRequest()); var headers = await call.ResponseHeadersAsync.DefaultTimeout(); - Assert.IsFalse(await call.ResponseStream.MoveNext(CancellationToken.None).DefaultTimeout()); + var moveNextTask = call.ResponseStream.MoveNext(CancellationToken.None); + if (statusCode == StatusCode.OK) + { + Assert.IsFalse(await moveNextTask.DefaultTimeout()); + } + else + { + var ex = await ExceptionAssert.ThrowsAsync(() => moveNextTask).DefaultTimeout(); + Assert.AreEqual(statusCode, ex.StatusCode); + Assert.AreEqual(statusMessage, ex.Status.Detail); + } // Assert Assert.NotNull(responseMessage); Assert.IsFalse(responseMessage!.TrailingHeaders().Any()); // sanity check that there are no trailers - Assert.AreEqual(StatusCode.OK, call.GetStatus().StatusCode); - Assert.AreEqual("Detail!", call.GetStatus().Detail); + Assert.AreEqual(statusCode, call.GetStatus().StatusCode); + Assert.AreEqual(statusMessage, call.GetStatus().Detail); Assert.AreEqual(0, headers.Count); Assert.AreEqual(0, call.GetTrailers().Count);