diff --git a/Directory.Packages.props b/Directory.Packages.props index 096f72701..e5914c97a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -56,6 +56,7 @@ + diff --git a/Grpc.DotNet.sln b/Grpc.DotNet.sln index 24ff6e3dc..1e04a7f6a 100644 --- a/Grpc.DotNet.sln +++ b/Grpc.DotNet.sln @@ -140,6 +140,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Grpc.HealthCheck.Tests", "t EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Grpc.Reflection.Tests", "test\Grpc.Reflection.Tests\Grpc.Reflection.Tests.csproj", "{857C5B4B-E2A8-4ACA-98FB-5E592E2224CC}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Grpc.StatusProto", "src\Grpc.StatusProto\Grpc.StatusProto.csproj", "{C01E4F44-9AB0-4478-A453-C88CCB49A4F1}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Grpc.StatusProto.Tests", "test\Grpc.StatusProto.Tests\Grpc.StatusProto.Tests.csproj", "{E49FA5BF-4D67-4C95-9543-8E9FCEAF3609}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -302,6 +306,14 @@ Global {857C5B4B-E2A8-4ACA-98FB-5E592E2224CC}.Debug|Any CPU.Build.0 = Debug|Any CPU {857C5B4B-E2A8-4ACA-98FB-5E592E2224CC}.Release|Any CPU.ActiveCfg = Release|Any CPU {857C5B4B-E2A8-4ACA-98FB-5E592E2224CC}.Release|Any CPU.Build.0 = Release|Any CPU + {C01E4F44-9AB0-4478-A453-C88CCB49A4F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C01E4F44-9AB0-4478-A453-C88CCB49A4F1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C01E4F44-9AB0-4478-A453-C88CCB49A4F1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C01E4F44-9AB0-4478-A453-C88CCB49A4F1}.Release|Any CPU.Build.0 = Release|Any CPU + {E49FA5BF-4D67-4C95-9543-8E9FCEAF3609}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E49FA5BF-4D67-4C95-9543-8E9FCEAF3609}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E49FA5BF-4D67-4C95-9543-8E9FCEAF3609}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E49FA5BF-4D67-4C95-9543-8E9FCEAF3609}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -355,6 +367,8 @@ Global {B4153E7F-5CF3-4DFB-A9D1-5E77A2FB2C48} = {8C62055F-8CD7-4859-9001-634D544DF2AE} {25544326-C145-4D05-A4C3-AC7D59E17196} = {CECC4AE8-9C4E-4727-939B-517CC2E58D65} {857C5B4B-E2A8-4ACA-98FB-5E592E2224CC} = {CECC4AE8-9C4E-4727-939B-517CC2E58D65} + {C01E4F44-9AB0-4478-A453-C88CCB49A4F1} = {8C62055F-8CD7-4859-9001-634D544DF2AE} + {E49FA5BF-4D67-4C95-9543-8E9FCEAF3609} = {CECC4AE8-9C4E-4727-939B-517CC2E58D65} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {CD5C2B19-49B4-480A-990C-36D98A719B07} diff --git a/src/Grpc.StatusProto/ExceptionExtensions.cs b/src/Grpc.StatusProto/ExceptionExtensions.cs new file mode 100644 index 000000000..b22397eb4 --- /dev/null +++ b/src/Grpc.StatusProto/ExceptionExtensions.cs @@ -0,0 +1,105 @@ +// Copyright 2023 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. + +using Google.Rpc; +using Grpc.Shared; + +namespace Grpc.Core; + +/// +/// Extensions methods for +/// +public static class ExceptionExtensions +{ + /// + /// Create a from an , + /// populating the Message and StackTrace from the exception. + /// Note: experimental API that can change or be removed without any prior notice. + /// + /// + /// + /// For example: + /// + /// try { /* ... */ + /// } + /// catch (Exception e) { + /// Google.Rpc.Status status = new() { + /// Code = (int)StatusCode.Internal, + /// Message = "Internal error", + /// Details = { + /// // populate debugInfo from the exception + /// Any.Pack(e.ToRpcDebugInfo()) + /// } + /// }; + /// // ... + /// } + /// + /// + /// + /// + /// Maximum number of inner exceptions to include in the StackTrace. Defaults + /// to not including any inner exceptions + /// + /// A new populated from the exception. + /// + public static DebugInfo ToRpcDebugInfo(this Exception exception, int innerDepth = 0) + { + ArgumentNullThrowHelper.ThrowIfNull(exception); + + var debugInfo = new DebugInfo(); + + var message = exception.Message; + var name = exception.GetType().FullName; + + // Populate the Detail from the exception type and message + debugInfo.Detail = message is null ? name : name + ": " + message; + + // Populate the StackEntries from the exception StackTrace + if (exception.StackTrace is not null) + { + var sr = new StringReader(exception.StackTrace); + var entry = sr.ReadLine(); + while (entry is not null) + { + debugInfo.StackEntries.Add(entry); + entry = sr.ReadLine(); + } + } + + // Add inner exceptions to the StackEntries + var inner = exception.InnerException; + while (innerDepth > 0 && inner is not null) + { + message = inner.Message; + name = inner.GetType().FullName; + debugInfo.StackEntries.Add("InnerException: " + (message is null ? name : name + ": " + message)); + + if (inner.StackTrace is not null) + { + var sr = new StringReader(inner.StackTrace); + var entry = sr.ReadLine(); + while (entry is not null) + { + debugInfo.StackEntries.Add(entry); + entry = sr.ReadLine(); + } + } + + inner = inner.InnerException; + --innerDepth; + } + + return debugInfo; + } +} diff --git a/src/Grpc.StatusProto/Grpc.StatusProto.csproj b/src/Grpc.StatusProto/Grpc.StatusProto.csproj new file mode 100644 index 000000000..da239737e --- /dev/null +++ b/src/Grpc.StatusProto/Grpc.StatusProto.csproj @@ -0,0 +1,29 @@ + + + + gRPC C# API for error handling using google/rpc/status.proto + gRPC RPC HTTP/2 + + true + true + net462;netstandard2.0;netstandard2.1 + README.md + + + + + + + + + + + + + + + + + + + diff --git a/src/Grpc.StatusProto/MetadataExtensions.cs b/src/Grpc.StatusProto/MetadataExtensions.cs new file mode 100644 index 000000000..4cbe2b6a1 --- /dev/null +++ b/src/Grpc.StatusProto/MetadataExtensions.cs @@ -0,0 +1,83 @@ +// Copyright 2023 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. + +using Google.Protobuf; +using Grpc.Shared; + +namespace Grpc.Core; + +/// +/// Extension methods for the Grpc.Core.Metadata +/// +public static class MetadataExtensions +{ + /// + /// Name of key in the metadata for the binary encoding of + /// + /// + public const string StatusDetailsTrailerName = "grpc-status-details-bin"; + + /// + /// Get the from the metadata. + /// Note: experimental API that can change or be removed without any prior notice. + /// + /// + /// if true then null is returned on a parsing error, + /// otherwise + /// will be thrown if the metadata cannot be parsed. + /// + /// The found or null if it was + /// not present or could the data could not be parsed. + /// + public static Google.Rpc.Status? GetRpcStatus(this Metadata metadata, bool ignoreParseError = false) + { + ArgumentNullThrowHelper.ThrowIfNull(metadata); + + var entry = metadata.Get(StatusDetailsTrailerName); + if (entry is null) + { + return null; + } + try + { + return Google.Rpc.Status.Parser.ParseFrom(entry.ValueBytes); + } + catch when (ignoreParseError) + { + // If the message is malformed just report there's no information. + return null; + } + } + + /// + /// Add to the metadata. + /// Any existing status in the metadata will be overwritten. + /// Note: experimental API that can change or be removed without any prior notice. + /// + /// + /// Status to add + public static void SetRpcStatus(this Metadata metadata, Google.Rpc.Status status) + { + ArgumentNullThrowHelper.ThrowIfNull(metadata); + ArgumentNullThrowHelper.ThrowIfNull(status); + + var entry = metadata.Get(StatusDetailsTrailerName); + while (entry is not null) + { + metadata.Remove(entry); + entry = metadata.Get(StatusDetailsTrailerName); + } + metadata.Add(StatusDetailsTrailerName, status.ToByteArray()); + } +} diff --git a/src/Grpc.StatusProto/README.md b/src/Grpc.StatusProto/README.md new file mode 100644 index 000000000..baef8e8f7 --- /dev/null +++ b/src/Grpc.StatusProto/README.md @@ -0,0 +1,301 @@ +# gRPC C# API for error handling with status.proto + +This is a protoype NuGet package providing C# and .NET client and server side support for the +[gRPC richer error model](https://grpc.io/docs/guides/error/#richer-error-model). + +This feature is already available in many other implementations including C++, +Go, Java and Python. + +This package has dependencies on these NuGet packages: +* `Google.Api.CommonProtos` - to provide the proto implementations used by the richer error model +* `Grpc.Core.Api` - for API classes such as `RpcException` + +## Error handling in gRPC + +The standard way for gRPC to report the success or failure of a gRPC call is for a +status code to be returned. If a call completes successfully the server returns an `OK` +status to the client, otherwise an error status code is returned with an optional string +error message that provides further details about what happened. This is known as the +_standard error model_ and is the official gRPC error model supported by all gRPC +implementations. + +There is another error model known as the _richer error model_ that allows additional +error details to be included by the server. These are expressed in protocol buffers +messages, and a +[set of standard error message types](https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto) +is defined to cover most needs. The protobuf binary encoding of this extra error +information is provided as trailing metadata in the response. + +For more information on the richer error model see the +[gRPC documentation on error handling](https://grpc.io/docs/guides/error/), +and the [Google APIs overview of the error model](https://cloud.google.com/apis/design/errors#error_model). + +## .NET implementation of the richer error model + +The error model is define by the protocol buffers files [status.proto](https://github.com/googleapis/googleapis/blob/master/google/rpc/status.proto) +and [error_details.proto](https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto), +and the `Google.Api.CommonProtos` NuGet package that provides the generated .NET classes +from these proto files. + +The error is encapsulated by an instance of `Google.Rpc.Status` and +returned in the trailing response metadata with well-known key `grpc-status-details-bin`. +Setting and reading this metadata is handled +for you when using the methods provided in this package. + +## Server Side + +The server side uses C#'s Object and Collection initializer syntax. + +The server returns the additional error information by throwing an `RpcException` that is +created from a `Google.Rpc.Status` which contains the details of the error. + +To add messages to the `Details` repeated field in `Google.Rpc.Status`, wrap each one in `Any.Pack()` - see example below. + +The `Google.Rpc.Status` extension method `ToRpcException` creates the appropriate `RpcException` from the status. + +__Example__ - creating and throwing a `RpcException`: +```C# +public override Task SayHello(HelloRequest request, ServerCallContext context) +{ + ArgumentNotNullOrEmpty(request.Name); + + return Task.FromResult(new HelloReply { Message = "Hello " + request.Name }); +} + +private static void ArgumentNotNullOrEmpty(string value, [CallerArgumentExpression(nameof(value))] string? paramName = null) +{ + if (string.IsNullOrEmpty(value)) + { + throw new Google.Rpc.Status + { + Code = (int)Code.InvalidArgument, + Message = "Bad request", + Details = + { + Any.Pack(new BadRequest + { + FieldViolations = + { + new BadRequest.Types.FieldViolation + { + Field = paramName, + Description = "Value is null or empty" + } + } + }) + } + }.ToRpcException(); + } +} +``` + +### A note on error codes + +Both `Grpc.Core.StatusCode` and `Google.Rpc.Code` define enums for a common +set of status codes such as `NotFound`, `PermissionDenied`, etc. They have the same values and are based on the codes defined +in [grpc/status.h](https://github.com/grpc/grpc/blob/master/include/grpc/status.h). + +The recommendation is to use the values in `Google.Rpc.Code` as a convention. +This is a must for Google APIs and strongly recommended for third party services. +But users can use a different domain of values if they want and and as long as their +services are mutually compatible, things will work fine. + +In the richer error model the `RpcException` will contain both a `Grpc.Core.Status` (for the +standard error model) and a `Google.Rpc.Status` (for the richer error model), each with their +own status code. While an application is free to set these to different values we recommend +that they are set to the same value to avoid ambiguity. + +### Passing stack traces from the server to the client + +The richer error model defines a standard way of passing stack traces from the server to the +client. The `DebugInfo` message can be populated with stack traces and then it can +be included in the `Details` of the `Google.Rpc.Status`. + +This package includes the extension method `ToRpcDebugInfo` for `System.Exception` to help +create the `DebugInfo` message with the details from the exception. + +Example: + +```C# +try +{ + // ... +} +catch (Exception e) +{ + throw new Google.Rpc.Status + { + Code = (int)Google.Rpc.Code.Internal, + Message = "Internal error", + Details = + { + // populate debugInfo from the exception + Any.Pack(e.ToRpcDebugInfo()), + // Add any other messages to the details ... + } + }.ToRpcException(); +} +``` + +## Client Side + +There is an extension method to retrieve a `Google.Rpc.Status` from the metadata in +an `RpcException`. + +Once the `Google.Rpc.Status` has been retrieved the messages in the `Details` +can be unpacked. There are two ways of doing this: + +- calling `GetDetail()` with one of the expected message types +- iterating over all the messages in the `Details` using `UnpackDetailMessage()` + +__Example__ - calling `GetDetail()`: + +```C# +void PrintError(RpcException ex) +{ + // Get the status from the RpcException + Google.Rpc.Status? rpcStatus = ex.GetRpcStatus(); // Extension method + + if (rpcStatus != null) + { + Console.WriteLine($"Google.Rpc Status: Code: {rpcStatus.Code}, Message: {rpcStatus.Message}"); + + // Try and get the ErrorInfo from the details + ErrorInfo? errorInfo = rpcStatus.GetDetail(); + if (errorInfo != null) + { + Console.WriteLine($"\tErrorInfo: Reason: {errorInfo.Reason}, Domain: {errorInfo.Domain}"); + foreach (var md in errorInfo.Metadata) + { + Console.WriteLine($"\tKey: {md.Key}, Value: {md.Value}"); + } + } + // etc, for any other messages expected in the Details ... + } +} +``` + +__Example__ - iterating over all the messages in the `Details`: + +```C# +void PrintStatusDetails(RpcException ex) +{ + // Get the status from the RpcException + Google.Rpc.Status? rpcStatus = ex.GetRpcStatus(); // Extension method + + if (rpcStatus != null) + { + // Decode each message item in the details in turn + foreach (var msg in rpcStatus.UnpackDetailMessages()) + { + switch (msg) + { + case ErrorInfo errorInfo: + Console.WriteLine($"ErrorInfo: Reason: {errorInfo.Reason}, Domain: {errorInfo.Domain}"); + foreach (var md in errorInfo.Metadata) + { + Console.WriteLine($"\tKey: {md.Key}, Value: {md.Value}"); + } + break; + + case BadRequest badRequest: + Console.WriteLine("BadRequest:"); + foreach (BadRequest.Types.FieldViolation fv in badRequest.FieldViolations) + { + Console.WriteLine($"\tField: {fv.Field}, Description: {fv.Description}"); + } + break; + + // Other cases handled here ... + } + } + } + +``` + +## Returning errors within gRPC streams + +The model described above allows you to return an error status when the gRPC call finishes. + +As an extension to the richer error model you may want to allow servers to send back +multiple statuses when streaming responses without terminating the call. + +One way of doing this is to include a `google.rpc.Status` message in the definition +of the response messages returned by the server. The client should also be aware +that it may receive a status in the response. + +For example: + + +```protobuf +service WidgetLookupProvider { + rpc streamingLookup(stream WidgetReq) returns (stream WidgetRsp) {} +} + +message WidgetReq { + string widget_name = 1; +} + +message WidgetRsp { + oneof message{ + // details when ok + string widget_details = 1; + // or error details + google.rpc.Status status = 2; + } +} +``` + +Note: the [status.proto](https://github.com/googleapis/googleapis/blob/master/google/rpc/status.proto) +and [error_details.proto](https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto) +files are provided in the `Google.Api.CommonProtos` NuGet package. + +Example server code fragment: +```C# +await foreach (var request in requestStream.ReadAllAsync()) +{ + var response = new WidgetRsp(); + + // ... process the request ... + + // to return an error + if (error) + { + response.Status = new Google.Rpc.Status { /* ... */ }; + } + else + { + response.WidgetDetails = "the details"; + } +} +``` + +Example client code fragment: +```C# +// reading the responses +var responseReaderTask = Task.Run(async () => +{ + await foreach (var rsp in call.ResponseStream.ReadAllAsync()) + { + switch (rsp.MessageCase) + { + case WidgetRsp.MessageOneofCase.WidgetDetails: + // ... processes the details ... + break; + case WidgetRsp.MessageOneofCase.Status: + // ... handle the error ... + break; + } + } +}); + +// sending the requests +foreach (var request in requests) +{ + await call.RequestStream.WriteAsync(request); +} +``` + +## See also +* [gRPC richer error model](https://grpc.io/docs/guides/error/#richer-error-model) +* [Google.Api.CommonProtos](https://cloud.google.com/dotnet/docs/reference/Google.Api.CommonProtos/latest/Google.Api) diff --git a/src/Grpc.StatusProto/RpcExceptionExtensions.cs b/src/Grpc.StatusProto/RpcExceptionExtensions.cs new file mode 100644 index 000000000..5a7cfa3f1 --- /dev/null +++ b/src/Grpc.StatusProto/RpcExceptionExtensions.cs @@ -0,0 +1,37 @@ +// Copyright 2023 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. + +using Grpc.Shared; + +namespace Grpc.Core; + +/// +/// Extensions to for handling rich error model. +/// +public static class RpcExceptionExtensions +{ + /// + /// Retrieves the message containing extended error information + /// from the trailers in an , if present. + /// Note: experimental API that can change or be removed without any prior notice. + /// + /// The RPC exception to retrieve details from. Must not be null. + /// The message specified in the exception, or null + /// if there is no such information. + public static Google.Rpc.Status? GetRpcStatus(this RpcException ex) + { + ArgumentNullThrowHelper.ThrowIfNull(ex); + return ex.Trailers.GetRpcStatus(); + } +} diff --git a/src/Grpc.StatusProto/RpcStatusExtensions.cs b/src/Grpc.StatusProto/RpcStatusExtensions.cs new file mode 100644 index 000000000..a3c8efd6a --- /dev/null +++ b/src/Grpc.StatusProto/RpcStatusExtensions.cs @@ -0,0 +1,112 @@ +// Copyright 2023 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. + +using Grpc.Shared; + +namespace Grpc.Core; + +/// +/// Extensions for to retrieve detailed error information. +/// Based on ideas from: +/// https://github.com/googleapis/gax-dotnet/blob/main/Google.Api.Gax.Grpc/RpcExceptionExtensions.cs +/// +public static class RpcStatusExtensions +{ + /// + /// Create a from the + /// Note: experimental API that can change or be removed without any prior notice. + /// + /// + /// + /// The and in the + /// within the exception are populated from the details in the + /// + /// + /// + /// + /// Example: + /// + /// throw new Google.Rpc.Status { + /// Code = (int) StatusCode.NotFound, + /// Message = "Simple error message", + /// Details = { + /// Any.Pack(new ErrorInfo { Domain = "example", Reason = "some reason" }) + /// } + /// }.ToRpcException(); + /// + /// + /// + /// + /// The RPC status. Must not be null + /// A populated with the details from the status. + public static RpcException ToRpcException(this Google.Rpc.Status status) + { + ArgumentNullThrowHelper.ThrowIfNull(status); + + // Both Grpc.Core.StatusCode and Google.Rpc.Code define enums for a common + // set of status codes such as "NotFound", "PermissionDenied", etc. They have the same + // values and are based on the codes defined "grpc/status.h" + // + // However applications can use a different domain of values if they want and and as + // long as their services are mutually compatible, things will work fine. + // + // If an application wants to explicitly set different status codes in Grpc.Core.Status + // and Google.Rpc.Status then use the ToRpcException below that takes additional parameters. + // + // Check here that we can convert Google.Rpc.Status.Code to Grpc.Core.StatusCode, + // and if not use StatusCode.Unknown. + var statusCode = System.Enum.IsDefined(typeof(StatusCode), status.Code) ? (StatusCode)status.Code : StatusCode.Unknown; + return status.ToRpcException(statusCode, status.Message); + } + + /// + /// Create a from the + /// Note: experimental API that can change or be removed without any prior notice. + /// + /// + /// + /// The and in the + /// within the exception are populated from the details in the + /// + /// + /// + /// + /// Example: + /// + /// throw new Google.Rpc.Status { + /// Code = (int) StatusCode.NotFound, + /// Message = "Simple error message", + /// Details = { + /// Any.Pack(new ErrorInfo { Domain = "example", Reason = "some reason" }) + /// } + /// }.ToRpcException(StatusCode.NotFound, "status message"); + /// + /// + /// + /// + /// + /// The status to set in the contained + /// The details to set in the contained + /// + public static RpcException ToRpcException(this Google.Rpc.Status status, StatusCode statusCode, string message) + { + ArgumentNullThrowHelper.ThrowIfNull(status); + + var metadata = new Metadata(); + metadata.SetRpcStatus(status); + return new RpcException( + new Grpc.Core.Status(statusCode, message), + metadata); + } +} diff --git a/test/Grpc.StatusProto.Tests/ExceptionExtensionsTest.cs b/test/Grpc.StatusProto.Tests/ExceptionExtensionsTest.cs new file mode 100644 index 000000000..dc17d9a3d --- /dev/null +++ b/test/Grpc.StatusProto.Tests/ExceptionExtensionsTest.cs @@ -0,0 +1,131 @@ +#region Copyright notice and license +// Copyright 2023 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. +#endregion + +using System.Text; +using Google.Rpc; +using NUnit.Framework; + +namespace Grpc.Core.Tests; + +/// +/// Tests for ExceptionExtensions +/// +[TestFixture] +public class ExceptionExtensionsTest +{ + [Test] + public void ToRpcDebugInfoTest() + { + try + { + // Arrange and Act + ThrowException("extra details"); + } + catch (Exception ex) + { + // Assert + var debugInfo = ex.ToRpcDebugInfo(); + Assert.IsNotNull(debugInfo); + Assert.AreEqual("System.ArgumentException: extra details", debugInfo.Detail); + + // Concatenate the returned stack traces into one string for checking + var stackTraces = ConcatStackTraces(debugInfo); + Console.WriteLine("Test stack trace data:"); + Console.WriteLine(stackTraces); + + // Test that some of the elements in the stack traces we expect are present. + // We are not doing a very strict comparision of the entire stack trace + // in case the format is slightly different in different environments. + Assert.IsTrue(stackTraces.Contains("ExceptionExtensionsTest.ThrowException")); + Assert.IsTrue(stackTraces.Contains("ExceptionExtensionsTest.ToRpcDebugInfoTest")); + Assert.IsFalse(stackTraces.Contains("InnerException:")); + } + } + + [Test] + public void ToRpcDebugInfo_WithInnerExceptionTest() + { + try + { + // Arrange and Act + ThrowException("extra details"); + } + catch (Exception ex) + { + // Assert + var debugInfo = ex.ToRpcDebugInfo(1); + Assert.IsNotNull(debugInfo); + Assert.AreEqual("System.ArgumentException: extra details", debugInfo.Detail); + + // Concatenate the returned stack traces into one string for checking + var stackTraces = ConcatStackTraces(debugInfo); + Console.WriteLine("Test stack trace data:"); + Console.WriteLine(stackTraces); + + // Test that some of the elements in the stack traces we expect are present. + // We are not doing a very strict comparision of the entire stack trace + // in case the format is slightly different in different environments. + Assert.IsTrue(stackTraces.Contains("ExceptionExtensionsTest.ThrowException")); + Assert.IsTrue(stackTraces.Contains("ExceptionExtensionsTest.ToRpcDebugInfo_WithInnerExceptionTest")); + Assert.IsTrue(stackTraces.Contains("InnerException: System.ApplicationException: inner exception")); + } + } + + /// + /// Throw an exception that contains an inner exception so that we + /// produce a stack trace for the tests. + /// + /// + /// + private void ThrowException(string message) + { + try + { + ThrowInnerException("inner exception"); + } + catch (Exception ex) + { + throw new ArgumentException(message, ex); + } + } + + /// + /// Throw an exception that will be the inner exception in the tests + /// + /// + /// + private void ThrowInnerException(string message) + { + throw new System.ApplicationException(message); + } + + /// + /// Join the stack entries into one string + /// + /// + /// + private string ConcatStackTraces(DebugInfo debugInfo) + { + var sb = new StringBuilder(); + + foreach (var stackEntry in debugInfo.StackEntries) + { + sb.AppendLine(stackEntry); + } + + return sb.ToString(); + } +} diff --git a/test/Grpc.StatusProto.Tests/Grpc.StatusProto.Tests.csproj b/test/Grpc.StatusProto.Tests/Grpc.StatusProto.Tests.csproj new file mode 100644 index 000000000..79555410c --- /dev/null +++ b/test/Grpc.StatusProto.Tests/Grpc.StatusProto.Tests.csproj @@ -0,0 +1,13 @@ + + + net462;net6.0;net7.0;net8.0 + true + + + + + + + + + diff --git a/test/Grpc.StatusProto.Tests/MetadataExtensionsTest.cs b/test/Grpc.StatusProto.Tests/MetadataExtensionsTest.cs new file mode 100644 index 000000000..a21b882d6 --- /dev/null +++ b/test/Grpc.StatusProto.Tests/MetadataExtensionsTest.cs @@ -0,0 +1,162 @@ +#region Copyright notice and license +// Copyright 2023 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. +#endregion + +using Google.Protobuf.WellKnownTypes; +using Google.Rpc; +using NUnit.Framework; +using Google.Protobuf; + +namespace Grpc.Core.Tests; + +/// +/// Tests for MetadataExtensions +/// +[TestFixture] +public class MetadataExtensionsTest +{ + // creates a status to use in the tests + private readonly Google.Rpc.Status status = new() + { + Code = (int)StatusCode.NotFound, + Message = "Simple error message", + Details = + { + Any.Pack(new ErrorInfo + { + Domain = "some domain", + Reason = "a reason" + }), + Any.Pack(new RequestInfo + { + RequestId = "request id", + ServingData = "data" + }), + } + }; + + [Test] + public void SetRpcStatusTest() + { + // Arrange + var metadata = new Metadata(); + + // Act + metadata.SetRpcStatus(status); + + // Assert + var entry = metadata.Get(MetadataExtensions.StatusDetailsTrailerName); + Assert.IsNotNull(entry); + var sts = Google.Rpc.Status.Parser.ParseFrom(entry!.ValueBytes); + Assert.AreEqual(status, sts); + } + + [Test] + public void SetRpcStatus_MultipleTimes() + { + // Arrange + Google.Rpc.Status status1 = new() + { + Code = (int)StatusCode.NotFound, + Message = "first" + }; + + Google.Rpc.Status status2 = new() + { + Code = (int)StatusCode.NotFound, + Message = "second" + }; + + Google.Rpc.Status status3 = new() + { + Code = (int)StatusCode.NotFound, + Message = "third" + }; + var metadata = new Metadata(); + + // Act - set the status three times + metadata.SetRpcStatus(status1); + metadata.SetRpcStatus(status2); + metadata.SetRpcStatus(status3); + + // Assert - only the last one should be in the metadata + Assert.AreEqual(1, metadata.Count); + + var entry = metadata.Get(MetadataExtensions.StatusDetailsTrailerName); + Assert.IsNotNull(entry); + var sts = Google.Rpc.Status.Parser.ParseFrom(entry!.ValueBytes); + Assert.AreEqual(status3, sts); + } + + [Test] + public void GetRpcStatus_OK() + { + // Arrange + var metadata = new Metadata(); + metadata.SetRpcStatus(status); + + // Act - retrieve the status from the metadata + var sts = metadata.GetRpcStatus(); + + // Assert - status retrieved ok + Assert.IsNotNull(sts); + Assert.AreEqual(status, sts); + } + + [Test] + public void GetRpcStatus_NotFound() + { + // Arrange + var metadata = new Metadata(); + + // Act - try and retrieve the non-existent status from the metadata + var sts = metadata.GetRpcStatus(); + + // Assert - not found + Assert.IsNull(sts); + } + + [Test] + public void GetRpcStatus_BadEncoding() + { + // Arrange - create badly encoded status in the metadata + var metadata = new Metadata + { + { MetadataExtensions.StatusDetailsTrailerName, new byte[] { 1, 2, 3 } } + }; + + // Act - try and retrieve the badly formed status from the metadata + var sts = metadata.GetRpcStatus(ignoreParseError: true); + + // Assert - not found as it could not be decoded + Assert.IsNull(sts); + } + + [Test] + public void GetRpcStatus_BadEncodingWithException() + { + // Arrange - create badly encoded status in the metadata + var metadata = new Metadata + { + { MetadataExtensions.StatusDetailsTrailerName, new byte[] { 1, 2, 3 } } + }; + + // Act and Assert + // Try and retrieve the status from the metadata and expect an exception + // because it could not be decoded + _ = Assert.Throws(() => metadata.GetRpcStatus(ignoreParseError: false)); + } + +} diff --git a/test/Grpc.StatusProto.Tests/RpcExceptionExtensionsTest.cs b/test/Grpc.StatusProto.Tests/RpcExceptionExtensionsTest.cs new file mode 100644 index 000000000..80fdfa5ef --- /dev/null +++ b/test/Grpc.StatusProto.Tests/RpcExceptionExtensionsTest.cs @@ -0,0 +1,87 @@ +#region Copyright notice and license +// Copyright 2023 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. +#endregion + +using Google.Protobuf.WellKnownTypes; +using Google.Rpc; +using NUnit.Framework; + +namespace Grpc.Core.Tests; + +/// +/// Tests for RpcExceptionExtensions +/// +[TestFixture] +public class RpcExceptionExtensionsTest +{ + // creates a status to use in the tests + private readonly Google.Rpc.Status status = new() + { + Code = (int)StatusCode.NotFound, + Message = "Simple error message", + Details = + { + Any.Pack(new ErrorInfo + { + Domain = "some domain", + Reason = "a reason" + }), + Any.Pack(new RequestInfo + { + RequestId = "request id", + ServingData = "data" + }), + } + }; + + [Test] + public void GetRpcStatus_OK() + { + // Act + var exception = status.ToRpcException(); + + // Assert - check the contents of the exception + Assert.AreEqual(status.Code, (int)exception.StatusCode); + Assert.AreEqual(status.Message, exception.Status.Detail); + var sts = exception.GetRpcStatus(); + Assert.IsNotNull(sts); + Assert.AreEqual(status, sts); + } + + [Test] + public void GetRpcStatus_NotFound() + { + // Act + var exception = new RpcException(new Core.Status()); + + // Assert - the exception does not contain a RpcStatus + var sts = exception.GetRpcStatus(); + Assert.IsNull(sts); + } + + [Test] + public void GetRpcStatus_SetCodeAndMessage() + { + // Arrange and Act - create the exception with status code and message + var exception = status.ToRpcException(StatusCode.Aborted, "Different message"); + + // Assert - check the details in the exception + Assert.AreEqual(StatusCode.Aborted, exception.StatusCode); + Assert.AreEqual("Different message", exception.Status.Detail); + var sts = exception.GetRpcStatus(); + Assert.IsNotNull(sts); + Assert.AreEqual(status, sts); + } +} diff --git a/test/Grpc.StatusProto.Tests/RpcStatusExtensionsTest.cs b/test/Grpc.StatusProto.Tests/RpcStatusExtensionsTest.cs new file mode 100644 index 000000000..1d03e58c7 --- /dev/null +++ b/test/Grpc.StatusProto.Tests/RpcStatusExtensionsTest.cs @@ -0,0 +1,423 @@ +#region Copyright notice and license +// Copyright 2023 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. +#endregion + +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Google.Rpc; +using NUnit.Framework; + +namespace Grpc.Core.Tests; + +/// +/// Tests for RpcStatusExtensions +/// +[TestFixture] +public class RpcStatusExtensionsTest +{ + [Test] + public void ToRpcExceptionTest() + { + // Arrange - create a status + var status = CreateFullStatus(); + // Act - get exception from the status + var ex = status.ToRpcException(); + + // Assert - check the details in the exception + Assert.IsNotNull(ex); + + var grpcSts = ex.Status; + Assert.AreEqual(StatusCode.ResourceExhausted, grpcSts.StatusCode); + Assert.AreEqual("Test", grpcSts.Detail); + + var sts = ex.GetRpcStatus(); + Assert.IsNotNull(sts); + Assert.AreEqual(status, sts); + } + + [Test] + public void ToRpcExceptionWithParamsTest() + { + // Arrange - create a status + var status = CreateFullStatus(); + + // Act - get exception from the status with specific parameters + var ex = status.ToRpcException(StatusCode.Cancelled, "status message"); + Assert.IsNotNull(ex); + + // Assert - check the details in the exception + var grpcSts = ex.Status; + Assert.AreEqual(StatusCode.Cancelled, grpcSts.StatusCode); + Assert.AreEqual("status message", grpcSts.Detail); + + var sts = ex.GetRpcStatus(); + Assert.IsNotNull(sts); + Assert.AreEqual(status, sts); + } + + [Test] + public void GetStatusDetailTest() + { + // Arrange - create a status + // The detailsMap contains all the Messages added to the status so + // these can be used in the comparisions when then are retrieved later + var detailsMap = new Dictionary(); + var status = CreateFullStatus(detailsMap); + + // Act + var badRequest = status.GetDetail(); + // Assert + Assert.IsNotNull(badRequest); + var expected = detailsMap["badRequest"]; + Assert.AreEqual(expected, badRequest); + + // Act + var errorInfo = status.GetDetail(); + // Assert + Assert.IsNotNull(errorInfo); + expected = detailsMap["errorInfo"]; + Assert.AreEqual(expected, errorInfo); + + // Act + var retryInfo = status.GetDetail(); + // Assert + Assert.IsNotNull(retryInfo); + expected = detailsMap["retryInfo"]; + Assert.AreEqual(expected, retryInfo); + + // Act + var debugInfo = status.GetDetail(); + // Assert + Assert.IsNotNull(debugInfo); + expected = detailsMap["debugInfo"]; + Assert.AreEqual(expected, debugInfo); + + // Act + var quotaFailure = status.GetDetail(); + // Assert + Assert.IsNotNull(quotaFailure); + expected = detailsMap["quotaFailure"]; + Assert.AreEqual(expected, quotaFailure); + + // Act + var preconditionFailure = status.GetDetail(); + // Assert + Assert.IsNotNull(preconditionFailure); + expected = detailsMap["preconditionFailure"]; + Assert.AreEqual(expected, preconditionFailure); + + // Act + var requestInfo = status.GetDetail(); + // Assert + Assert.IsNotNull(requestInfo); + expected = detailsMap["requestInfo"]; + Assert.AreEqual(expected, requestInfo); + + // Act + var help = status.GetDetail(); + // Assert + Assert.IsNotNull(help); + expected = detailsMap["help"]; + Assert.AreEqual(expected, help); + + // Act + var localizedMessage = status.GetDetail(); + // Assert + Assert.IsNotNull(localizedMessage); + expected = detailsMap["localizedMessage"]; + Assert.AreEqual(expected, localizedMessage); + } + + [Test] + public void GetStatusDetail_NotFound() + { + // Arrange - create a status with only a few details + // The detailsMap contains all the Messages added to the status so + // these can be used in the comparisions when then are retrieved later + var detailsMap = new Dictionary(); + var status = CreatePartialStatus(detailsMap); + + // Act - try and retieve non-existent BadRequest from the status + var badRequest = status.GetDetail(); + // Assert + Assert.IsNull(badRequest); + } + + [Test] + public void UnpackDetailMessageTest() + { + // Arrange - create a status + // The detailsMap contains all the Messages added to the status so + // these can be used in the comparisions when then are retrieved later + var detailsMap = new Dictionary(); + var status = CreateFullStatus(detailsMap); + + // foundSet will contain the messages found in the status so we can + // check all those expected were present + var foundSet = new HashSet(); + + // Act and Assert - iterate over all the messages in the status + // and check they contain what is expected + foreach (var msg in status.UnpackDetailMessages()) + { + switch (msg) + { + case ErrorInfo errorInfo: + { + var expected = detailsMap["errorInfo"]; + Assert.AreEqual(expected, errorInfo); + foundSet.Add("errorInfo"); + break; + } + + case BadRequest badRequest: + { + var expected = detailsMap["badRequest"]; + Assert.AreEqual(expected, badRequest); + foundSet.Add("badRequest"); + break; + } + + case RetryInfo retryInfo: + { + var expected = detailsMap["retryInfo"]; + Assert.AreEqual(expected, retryInfo); + foundSet.Add("retryInfo"); + break; + } + + case DebugInfo debugInfo: + { + var expected = detailsMap["debugInfo"]; + Assert.AreEqual(expected, debugInfo); + foundSet.Add("debugInfo"); + break; + } + + case QuotaFailure quotaFailure: + { + var expected = detailsMap["quotaFailure"]; + Assert.AreEqual(expected, quotaFailure); + foundSet.Add("quotaFailure"); + break; + } + + case PreconditionFailure preconditionFailure: + { + var expected = detailsMap["preconditionFailure"]; + Assert.AreEqual(expected, preconditionFailure); + foundSet.Add("preconditionFailure"); + break; + } + + case RequestInfo requestInfo: + { + var expected = detailsMap["requestInfo"]; + Assert.AreEqual(expected, requestInfo); + foundSet.Add("requestInfo"); + break; + } + + case ResourceInfo resourceInfo: + { + var expected = detailsMap["resourceInfo"]; + Assert.AreEqual(expected, resourceInfo); + foundSet.Add("resourceInfo"); + break; + } + + case Help help: + { + var expected = detailsMap["help"]; + Assert.AreEqual(expected, help); + foundSet.Add("help"); + break; + } + + case LocalizedMessage localizedMessage: + { + var expected = detailsMap["localizedMessage"]; + Assert.AreEqual(expected, localizedMessage); + foundSet.Add("localizedMessage"); + break; + } + } + } + + // check everything was returned + Assert.AreEqual(detailsMap.Count, foundSet.Count); + + } + + private static Google.Rpc.Status CreatePartialStatus(Dictionary? detailsMap = null) + { + var retryInfo = new RetryInfo + { + RetryDelay = Duration.FromTimeSpan(new TimeSpan(0, 0, 5)) + }; + + var debugInfo = new DebugInfo() + { + StackEntries = { "stack1", "stack2" }, + Detail = "detail" + }; + + // add details to a map for later checking + if (detailsMap != null) + { + detailsMap.Clear(); + detailsMap.Add("retryInfo", retryInfo); + detailsMap.Add("debugInfo", debugInfo); + } + + var status = new Google.Rpc.Status() + { + Code = (int)StatusCode.Unavailable, + Message = "partial status", + Details = + { + Any.Pack(retryInfo), + Any.Pack(debugInfo), + } + }; + + return status; + } + + static Google.Rpc.Status CreateFullStatus(Dictionary? detailsMap = null) + { + var errorInfo = new ErrorInfo() + { + Domain = "Rich Error Model Demo", + Reason = "Full error requested in the demo", + Metadata = + { + { "key1", "value1" }, + { "key2", "value2" } + } + }; + + var badRequest = new BadRequest() + { + FieldViolations = + { + new BadRequest.Types.FieldViolation() + { + Field = "field", Description = "description" + } + } + }; + + var retryInfo = new RetryInfo + { + RetryDelay = Duration.FromTimeSpan(new TimeSpan(0, 0, 5)) + }; + + var debugInfo = new DebugInfo() + { + StackEntries = { "stack1", "stack2" }, + Detail = "detail" + }; + + var quotaFailure = new QuotaFailure() + { + Violations = + { + new QuotaFailure.Types.Violation() + { + Description = "Too much disk space used", + Subject = "Disk23" + } + } + }; + + var preconditionFailure = new PreconditionFailure() + { + Violations = + { + new PreconditionFailure.Types.Violation() + { + Type = "type", Subject = "subject", Description = "description" + } + } + }; + + var requestInfo = new RequestInfo() + { + RequestId = "reqId", + ServingData = "data" + }; + + var resourceInfo = new ResourceInfo() + { + ResourceType = "type", + ResourceName = "name", + Owner = "owner", + Description = "description" + }; + + var help = new Help() + { + Links = + { + new Help.Types.Link() { Url="url1", Description="desc1" }, + new Help.Types.Link() { Url="url2", Description="desc2" }, + } + }; + + var localizedMessage = new LocalizedMessage() + { + Locale = "en-GB", + Message = "Example localised error message" + }; + + // add details to a map for later checking + if (detailsMap != null) + { + detailsMap.Clear(); + detailsMap.Add("badRequest", badRequest); + detailsMap.Add("errorInfo", errorInfo); + detailsMap.Add("retryInfo", retryInfo); + detailsMap.Add("debugInfo", debugInfo); + detailsMap.Add("quotaFailure", quotaFailure); + detailsMap.Add("preconditionFailure", preconditionFailure); + detailsMap.Add("requestInfo", requestInfo); + detailsMap.Add("resourceInfo", resourceInfo); + detailsMap.Add("help", help); + detailsMap.Add("localizedMessage", localizedMessage); + } + + var status = new Google.Rpc.Status() + { + Code = (int)StatusCode.ResourceExhausted, + Message = "Test", + Details = + { + Any.Pack(badRequest), + Any.Pack(errorInfo), + Any.Pack(retryInfo), + Any.Pack(debugInfo), + Any.Pack(quotaFailure), + Any.Pack(preconditionFailure), + Any.Pack(requestInfo), + Any.Pack(resourceInfo), + Any.Pack(help), + Any.Pack(localizedMessage) + } + }; + + return status; + } +}