Skip to content

Commit 47e1c79

Browse files
authored
Include trailers in client with RpcException (#567)
1 parent 3fb0862 commit 47e1c79

File tree

4 files changed

+186
-28
lines changed

4 files changed

+186
-28
lines changed

src/Grpc.Net.Client/Internal/GrpcCall.cs

Lines changed: 40 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ public void EnsureNotDisposed()
190190
public Exception CreateCanceledStatusException()
191191
{
192192
var status = (CallTask.IsCompletedSuccessfully) ? CallTask.Result : new Status(StatusCode.Cancelled, string.Empty);
193-
return new RpcException(status);
193+
return CreateRpcException(status);
194194
}
195195

196196
/// <summary>
@@ -241,6 +241,9 @@ public Task<TResponse> GetResponseAsync()
241241
// An explicitly specified status header has priority over other failing statuses
242242
if (GrpcProtocolHelpers.TryGetStatusCore(httpResponse.Headers, out var status))
243243
{
244+
// Trailers are in the header because there is no message.
245+
// Note that some default headers will end up in the trailers (e.g. Date, Server).
246+
_trailers = GrpcProtocolHelpers.BuildMetadata(httpResponse.Headers);
244247
return status;
245248
}
246249

@@ -298,16 +301,35 @@ public Metadata GetTrailers()
298301
{
299302
using (StartScope())
300303
{
301-
if (_trailers == null)
304+
if (!TryGetTrailers(out var trailers))
302305
{
303-
ValidateTrailersAvailable();
306+
// Throw InvalidOperationException here because documentation on GetTrailers says that
307+
// InvalidOperationException is thrown if the call is not complete.
308+
throw new InvalidOperationException("Can't get the call trailers because the call has not completed successfully.");
309+
}
304310

305-
Debug.Assert(HttpResponse != null);
306-
_trailers = GrpcProtocolHelpers.BuildMetadata(HttpResponse.TrailingHeaders);
311+
return trailers;
312+
}
313+
}
314+
315+
private bool TryGetTrailers([NotNullWhen(true)] out Metadata? trailers)
316+
{
317+
if (_trailers == null)
318+
{
319+
// Trailers are read from the end of the request.
320+
// If the request isn't finished then we can't get the trailers.
321+
if (!ResponseFinished)
322+
{
323+
trailers = null;
324+
return false;
307325
}
308326

309-
return _trailers;
327+
Debug.Assert(HttpResponse != null);
328+
_trailers = GrpcProtocolHelpers.BuildMetadata(HttpResponse.TrailingHeaders);
310329
}
330+
331+
trailers = _trailers;
332+
return true;
311333
}
312334

313335
private void SetMessageContent(TRequest request, HttpRequestMessage message)
@@ -348,7 +370,7 @@ private void CancelCall(Status status)
348370

349371
if (!Channel.ThrowOperationCanceledOnCancellation)
350372
{
351-
_metadataTcs.TrySetException(new RpcException(status));
373+
_metadataTcs.TrySetException(CreateRpcException(status));
352374
}
353375
else
354376
{
@@ -369,6 +391,12 @@ private void CancelCall(Status status)
369391
return null;
370392
}
371393

394+
internal RpcException CreateRpcException(Status status)
395+
{
396+
TryGetTrailers(out var trailers);
397+
return new RpcException(status, trailers ?? Metadata.Empty);
398+
}
399+
372400
private async ValueTask RunCall(HttpRequestMessage request)
373401
{
374402
using (StartScope())
@@ -473,17 +501,17 @@ private async ValueTask RunCall(HttpRequestMessage request)
473501
if (ex is OperationCanceledException)
474502
{
475503
status = (CallTask.IsCompletedSuccessfully) ? CallTask.Result : new Status(StatusCode.Cancelled, string.Empty);
476-
resolvedException = Channel.ThrowOperationCanceledOnCancellation ? ex : new RpcException(status.Value);
504+
resolvedException = Channel.ThrowOperationCanceledOnCancellation ? ex : CreateRpcException(status.Value);
477505
}
478506
else if (ex is RpcException rpcException)
479507
{
480508
status = rpcException.Status;
481-
resolvedException = new RpcException(status.Value);
509+
resolvedException = CreateRpcException(status.Value);
482510
}
483511
else
484512
{
485513
status = new Status(StatusCode.Internal, "Error starting gRPC call: " + ex.Message);
486-
resolvedException = new RpcException(status.Value);
514+
resolvedException = CreateRpcException(status.Value);
487515
}
488516

489517
_metadataTcs.TrySetException(resolvedException);
@@ -510,7 +538,7 @@ private void SetFailedResult(Status status)
510538
}
511539
else
512540
{
513-
_responseTcs.TrySetException(new RpcException(status));
541+
_responseTcs.TrySetException(CreateRpcException(status));
514542
}
515543
}
516544

@@ -526,7 +554,7 @@ public Exception CreateFailureStatusException(Status status)
526554
}
527555
else
528556
{
529-
return new RpcException(status);
557+
return CreateRpcException(status);
530558
}
531559
}
532560

@@ -721,18 +749,5 @@ private void DeadlineExceeded(object state)
721749
CancelCall(new Status(StatusCode.DeadlineExceeded, string.Empty));
722750
}
723751
}
724-
725-
private void ValidateTrailersAvailable()
726-
{
727-
// Response is finished
728-
if (ResponseFinished)
729-
{
730-
return;
731-
}
732-
733-
// Throw InvalidOperationException here because documentation on GetTrailers says that
734-
// InvalidOperationException is thrown if the call is not complete.
735-
throw new InvalidOperationException("Can't get the call trailers because the call has not completed successfully.");
736-
}
737752
}
738753
}

src/Grpc.Net.Client/Internal/HttpContentClientStreamReader.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ public Task<bool> MoveNext(CancellationToken cancellationToken)
8585
}
8686
else
8787
{
88-
return Task.FromException<bool>(new RpcException(status));
88+
return Task.FromException<bool>(_call.CreateRpcException(status));
8989
}
9090
}
9191

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
#region Copyright notice and license
2+
3+
// Copyright 2019 The gRPC Authors
4+
//
5+
// Licensed under the Apache License, Version 2.0 (the "License");
6+
// you may not use this file except in compliance with the License.
7+
// You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing, software
12+
// distributed under the License is distributed on an "AS IS" BASIS,
13+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
// See the License for the specific language governing permissions and
15+
// limitations under the License.
16+
17+
#endregion
18+
19+
using System.Linq;
20+
using System.Threading.Tasks;
21+
using Greet;
22+
using Grpc.AspNetCore.FunctionalTests.Infrastructure;
23+
using Grpc.Core;
24+
using Grpc.Tests.Shared;
25+
using NUnit.Framework;
26+
27+
namespace Grpc.AspNetCore.FunctionalTests.Server
28+
{
29+
[TestFixture]
30+
public class TrailerMetadataTests : FunctionalTestBase
31+
{
32+
[Test]
33+
public async Task GetTrailers_UnaryMethodSetStatusWithTrailers_TrailersAvailableInClient()
34+
{
35+
Task<HelloReply> UnaryDeadlineExceeded(HelloRequest request, ServerCallContext context)
36+
{
37+
context.ResponseTrailers.Add(new Metadata.Entry("Name", "the value was empty"));
38+
context.Status = new Status(StatusCode.InvalidArgument, "Validation failed");
39+
return Task.FromResult(new HelloReply());
40+
}
41+
42+
// Arrange
43+
SetExpectedErrorsFilter(writeContext =>
44+
{
45+
if (writeContext.LoggerName == "Grpc.Net.Client.Internal.GrpcCall" &&
46+
writeContext.EventId.Name == "ErrorReadingMessage" &&
47+
writeContext.Message == "Error reading message.")
48+
{
49+
return true;
50+
}
51+
52+
if (writeContext.LoggerName == "Grpc.Net.Client.Internal.GrpcCall" &&
53+
writeContext.EventId.Name == "GrpcStatusError" &&
54+
writeContext.Message == "Call failed with gRPC error status. Status code: 'InvalidArgument', Message: 'Validation failed'.")
55+
{
56+
return true;
57+
}
58+
59+
return false;
60+
});
61+
62+
var method = Fixture.DynamicGrpc.AddUnaryMethod<HelloRequest, HelloReply>(UnaryDeadlineExceeded);
63+
64+
var channel = CreateChannel();
65+
66+
var client = TestClientFactory.Create(channel, method);
67+
68+
// Act
69+
var call = client.UnaryCall(new HelloRequest());
70+
71+
var ex = await ExceptionAssert.ThrowsAsync<RpcException>(() => call.ResponseAsync).DefaultTimeout();
72+
73+
// Assert
74+
var trailers = call.GetTrailers();
75+
Assert.AreEqual(1, trailers.Count);
76+
Assert.AreEqual("the value was empty", trailers.Single(m => m.Key == "name").Value);
77+
78+
Assert.AreEqual(StatusCode.InvalidArgument, ex.StatusCode);
79+
Assert.AreEqual("Validation failed", ex.Status.Detail);
80+
Assert.AreEqual(1, ex.Trailers.Count);
81+
Assert.AreEqual("the value was empty", ex.Trailers.Single(m => m.Key == "name").Value);
82+
}
83+
84+
[Test]
85+
public async Task GetTrailers_UnaryMethodThrowsExceptionWithTrailers_TrailersAvailableInClient()
86+
{
87+
Task<HelloReply> UnaryDeadlineExceeded(HelloRequest request, ServerCallContext context)
88+
{
89+
var trailers = new Metadata();
90+
trailers.Add(new Metadata.Entry("Name", "the value was empty"));
91+
return Task.FromException<HelloReply>(new RpcException(new Status(StatusCode.InvalidArgument, "Validation failed"), trailers));
92+
}
93+
94+
// Arrange
95+
SetExpectedErrorsFilter(writeContext =>
96+
{
97+
if (writeContext.LoggerName == "Grpc.Net.Client.Internal.GrpcCall" &&
98+
writeContext.EventId.Name == "ErrorReadingMessage" &&
99+
writeContext.Message == "Error reading message.")
100+
{
101+
return true;
102+
}
103+
104+
if (writeContext.LoggerName == "Grpc.Net.Client.Internal.GrpcCall" &&
105+
writeContext.EventId.Name == "GrpcStatusError" &&
106+
writeContext.Message == "Call failed with gRPC error status. Status code: 'InvalidArgument', Message: 'Validation failed'.")
107+
{
108+
return true;
109+
}
110+
111+
if (writeContext.LoggerName == "SERVER Grpc.AspNetCore.Server.ServerCallHandler" &&
112+
writeContext.EventId.Name == "RpcConnectionError" &&
113+
writeContext.Message == "Error status code 'InvalidArgument' raised.")
114+
{
115+
return true;
116+
}
117+
118+
return false;
119+
});
120+
121+
var method = Fixture.DynamicGrpc.AddUnaryMethod<HelloRequest, HelloReply>(UnaryDeadlineExceeded);
122+
123+
var channel = CreateChannel();
124+
125+
var client = TestClientFactory.Create(channel, method);
126+
127+
// Act
128+
var call = client.UnaryCall(new HelloRequest());
129+
130+
var ex = await ExceptionAssert.ThrowsAsync<RpcException>(() => call.ResponseAsync).DefaultTimeout();
131+
132+
// Assert
133+
var trailers = call.GetTrailers();
134+
Assert.GreaterOrEqual(trailers.Count, 1);
135+
Assert.AreEqual("the value was empty", trailers.Single(m => m.Key == "name").Value);
136+
137+
Assert.AreEqual(StatusCode.InvalidArgument, ex.StatusCode);
138+
Assert.AreEqual("Validation failed", ex.Status.Detail);
139+
Assert.GreaterOrEqual(ex.Trailers.Count, 1);
140+
Assert.AreEqual("the value was empty", ex.Trailers.Single(m => m.Key == "name").Value);
141+
}
142+
}
143+
}

testassets/InteropTestsWebsite/Program.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ public static IHostBuilder CreateHostBuilder(string[] args) =>
4141
{
4242
// Support --port and --use_tls cmdline arguments normally supported
4343
// by gRPC interop servers.
44-
int port = context.Configuration.GetValue<int>("port", 50052);
45-
bool useTls = context.Configuration.GetValue<bool>("use_tls", false);
44+
var port = context.Configuration.GetValue<int>("port", 50052);
45+
var useTls = context.Configuration.GetValue<bool>("use_tls", false);
4646

4747
options.Limits.MinRequestBodyDataRate = null;
4848
options.ListenAnyIP(port, listenOptions =>

0 commit comments

Comments
 (0)