Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Dapr 1.13 extended error info #1228

Open
philliphoff opened this issue Jan 24, 2024 · 4 comments
Open

Support Dapr 1.13 extended error info #1228

philliphoff opened this issue Jan 24, 2024 · 4 comments
Assignees
Labels
good first issue Good for newcomers help wanted Extra attention is needed kind/enhancement New feature or request

Comments

@philliphoff
Copy link
Collaborator

Describe the feature

With 1.13, the Dapr runtime will start providing extended error info for operations (e.g. related to state stores). (See dapr/dapr#6068) While the mechanism for providing this info aligns with the "gRPC richer error model", there is currently no common library in .NET for its extraction.

This info should be exposed to users of the Dapr .NET SDK and might, technically, be so today but, given the lack of a standard library and the existing wrapping of RPC exceptions with custom Dapr exception types, it is not straightforward for users to get at and effectively use it. Therefore, the Dapr .NET SDK should provide some infrastructure to simplify its extraction.

Once added, docs should be updated to show users how to make use of the extended error info (see #1227).

Release Note

RELEASE NOTE:

@philliphoff philliphoff added the kind/enhancement New feature or request label Jan 24, 2024
@philliphoff
Copy link
Collaborator Author

Here's an example for how the error information can be extracted, today, with Dapr v1.13:

using Dapr;
using Dapr.Client;
using Grpc.Core;

var client = new DaprClientBuilder()
    .UseHttpEndpoint(/* ... */)
    .Build();

try
{
    /* Perform operation with DaprClient... */
}
catch (DaprException ex) when (ex.InnerException is RpcException rex)
{
    var details = rex.Trailers.FirstOrDefault(m => m.Key == "grpc-status-details-bin");

    if (details is not null)
    {
        Google.Rpc.Status status = Google.Rpc.Status.Parser.ParseFrom(details.ValueBytes);

        Console.WriteLine(status);

        Console.WriteLine($"Status:");
        Console.WriteLine($"Code: {status.Code}");
        Console.WriteLine($"Message: {status.Message}");

        foreach (var detail in status.Details)
        {
            switch (detail.TypeUrl)
            {
                case "type.googleapis.com/google.rpc.BadRequest":
                    var badRequest = Google.Rpc.BadRequest.Parser.ParseFrom(detail.Value);

                    Console.WriteLine("Bad Request:");
                    foreach (var fieldViolation in badRequest.FieldViolations)
                    {
                        Console.WriteLine($"Field: {fieldViolation.Field}");
                        Console.WriteLine($"Description: {fieldViolation.Description}");
                    }
                    break;

                case "type.googleapis.com/google.rpc.ErrorInfo":
                    var errorInfo = Google.Rpc.ErrorInfo.Parser.ParseFrom(detail.Value);
                    Console.WriteLine("Error Info:");
                    Console.WriteLine($"Reason: {errorInfo.Reason}");
                    Console.WriteLine($"Domain: {errorInfo.Domain}");
                    break;

                case "type.googleapis.com/google.rpc.Help":
                    var help = Google.Rpc.Help.Parser.ParseFrom(detail.Value);
                    Console.WriteLine("Help:");
                    Console.WriteLine($"Links:");
                    foreach (var link in help.Links)
                    {
                        Console.WriteLine($"  Description: {link.Description}");
                        Console.WriteLine($"  Url: {link.Url}");
                    }
                    break;

                case "type.googleapis.com/google.rpc.ResourceInfo":
                    var resourceInfo = Google.Rpc.ResourceInfo.Parser.ParseFrom(detail.Value);
                    Console.WriteLine("Resource Info:");
                    Console.WriteLine($"Resource Type: {resourceInfo.ResourceType}");
                    Console.WriteLine($"Resource Name: {resourceInfo.ResourceName}");
                    Console.WriteLine($"Owner: {resourceInfo.Owner}");
                    Console.WriteLine($"Description: {resourceInfo.Description}");
                    break;
            }
        }
    }
    else
    {
        Console.WriteLine($"RPC exception: {rex}");
    }
}
catch (Exception ex)
{
    Console.WriteLine($"Exception: {ex}");
}

@philliphoff philliphoff added help wanted Extra attention is needed good first issue Good for newcomers labels Jan 26, 2024
@philliphoff
Copy link
Collaborator Author

Here's a proposed API for how extended error info should be obtained by users:

  • The starting point would be a method TryGetExtendedErrorInfo() exposed directly from DaprException or as an extension method
  • If such info is available on the underlying RPC exception, the method performs the extraction and returns a DaprExtendedErrorInfo object which represents a collection of error "details"
  • Each Dapr-supported details type (i.e. bad request, error info, help, and resource info) is represented as its own type (derived from a base detail type)
  • These details types are independent but parallel their respective gRPC types

Question:
Why not just directly return the gRPC types?

Answer:
While the Dapr .NET SDK uses gRPC for communication with the sidecar, such use has generally been considered an implementation detail. There are some exceptions, for example, in the case where applications are calling other applications via gRPC but through the sidecar, but still underlying gRPC types are not exposed from the Dapr API. Exposing Dapr-specific types therefore seems appropriate and also has a benefit in that the complexities of the gRPC type system are not exposed to users.

using System.Diagnostics.CodeAnalysis;
using Grpc.Core;

namespace Dapr.Errors
{
    public enum DaprExtendedErrorDetailType
    {
        BadRequest,
        ErrorInfo,
        Help,
        ResourceInfo
    }

    public abstract record DaprExtendedErrorDetail(DaprExtendedErrorDetailType Type);

    public sealed record DaprBadRequestDetailFieldViolation(string Field, string Description);

    public sealed record DaprBadRequestDetail() : DaprExtendedErrorDetail(DaprExtendedErrorDetailType.BadRequest)
    {
        public DaprBadRequestDetailFieldViolation[] FieldViolations { get; init; } = Array.Empty<DaprBadRequestDetailFieldViolation>();
    }

    public sealed record DaprErrorInfoDetail(string Reason, string Domain) : DaprExtendedErrorDetail(DaprExtendedErrorDetailType.ErrorInfo);

    public sealed record DaprHelpDetailLink(string Url, string Description);

    public sealed record DaprHelpDetail() : DaprExtendedErrorDetail(DaprExtendedErrorDetailType.Help)
    {
        public DaprHelpDetailLink[] Links { get; init; } = Array.Empty<DaprHelpDetailLink>();
    }

    public sealed record DaprResourceInfoDetail(string ResourceType, string ResourceName, string Owner, string Description) : DaprExtendedErrorDetail(DaprExtendedErrorDetailType.ResourceInfo);

    public sealed record DaprExtendedErrorInfo(int Code, string Message)
    {
        public DaprExtendedErrorDetail[] Details { get; init; } = Array.Empty<DaprExtendedErrorDetail>();
    }

    public static class DaprExceptionExtensions
    {
        public static bool TryGetExtendedErrorInfo(this DaprException ex, [NotNullWhen(true)] out DaprExtendedErrorInfo? errorInfo)
        {
            errorInfo = null;

            if (ex.InnerException is RpcException rex)
            {
                var details = rex.Trailers.FirstOrDefault(m => m.Key == "grpc-status-details-bin");

                if (details is not null)
                {
                    Google.Rpc.Status status = Google.Rpc.Status.Parser.ParseFrom(details.ValueBytes);

                    errorInfo = new DaprExtendedErrorInfo(status.Code, status.Message)
                    {
                        Details =
                            status
                                .Details
                                .Select(
                                    detail => detail.TypeUrl switch
                                    {
                                        "type.googleapis.com/google.rpc.BadRequest" => ToDaprBadRequestDetail(Google.Rpc.BadRequest.Parser.ParseFrom(detail.Value)),
                                        "type.googleapis.com/google.rpc.ErrorInfo" => ToDaprErrorInfoDetail(Google.Rpc.ErrorInfo.Parser.ParseFrom(detail.Value)),
                                        "type.googleapis.com/google.rpc.Help" => ToDaprHelpDetail(Google.Rpc.Help.Parser.ParseFrom(detail.Value)),
                                        "type.googleapis.com/google.rpc.ResourceInfo" => ToDaprResourceInfoDetail(Google.Rpc.ResourceInfo.Parser.ParseFrom(detail.Value)),
                                        _ => (DaprExtendedErrorDetail?)null
                                    })
                                .WhereNotNull()
                                .ToArray()
                    };

                    return true;
                }
            }

            return false;
        }

        private static DaprBadRequestDetail ToDaprBadRequestDetail(Google.Rpc.BadRequest badRequest) => new DaprBadRequestDetail() { FieldViolations = badRequest.FieldViolations.Select(fieldViolation => new DaprBadRequestDetailFieldViolation(fieldViolation.Field, fieldViolation.Description)).ToArray() };

        private static DaprErrorInfoDetail ToDaprErrorInfoDetail(Google.Rpc.ErrorInfo errorInfo) => new DaprErrorInfoDetail(errorInfo.Reason, errorInfo.Domain);

        private static DaprHelpDetail ToDaprHelpDetail(Google.Rpc.Help help) => new DaprHelpDetail() { Links = help.Links.Select(link => new DaprHelpDetailLink(link.Url, link.Description)).ToArray() };

        private static DaprResourceInfoDetail ToDaprResourceInfoDetail(Google.Rpc.ResourceInfo resourceInfo) => new DaprResourceInfoDetail(resourceInfo.ResourceType, resourceInfo.ResourceName, resourceInfo.Owner, resourceInfo.Description);
    }

    public static class IEnumerableExtensions
    {
        public static IEnumerable<T> WhereNotNull<T>(this IEnumerable<T?> source) where T : class => source.Where(item => item is not null)!;
    }
}

Use of extended error info would then look like:

try
{
    /* Perform operation with DaprClient... */
}
catch (DaprException ex)
{
    if (ex.TryGetExtendedErrorInfo(out var details))
    {
        Console.WriteLine($"Status:");
        Console.WriteLine($"Code: {details.Code}");
        Console.WriteLine($"Message: {details.Message}");

        foreach (var detail in details.Details)
        {
            switch (detail)
            {
                case DaprBadRequestDetail badRequest:
                    Console.WriteLine("Bad Request:");
                    foreach (var fieldViolation in badRequest.FieldViolations)
                    {
                        Console.WriteLine($"Field: {fieldViolation.Field}");
                        Console.WriteLine($"Description: {fieldViolation.Description}");
                    }
                    break;

                case DaprErrorInfoDetail errorInfo:
                    Console.WriteLine("Error Info:");
                    Console.WriteLine($"Reason: {errorInfo.Reason}");
                    Console.WriteLine($"Domain: {errorInfo.Domain}");
                    break;

                case DaprHelpDetail help:
                    Console.WriteLine("Help:");
                    Console.WriteLine($"Links:");
                    foreach (var link in help.Links)
                    {
                        Console.WriteLine($"  Description: {link.Description}");
                        Console.WriteLine($"  Url: {link.Url}");
                    }
                    break;

                case DaprResourceInfoDetail resourceInfo:
                    Console.WriteLine("Resource Info:");
                    Console.WriteLine($"Resource Type: {resourceInfo.ResourceType}");
                    Console.WriteLine($"Resource Name: {resourceInfo.ResourceName}");
                    Console.WriteLine($"Owner: {resourceInfo.Owner}");
                    Console.WriteLine($"Description: {resourceInfo.Description}");
                    break;
            }
        }
    }
    else
    {
        Console.WriteLine($"Exception: {ex}");
    }
}

Open questions:

  • There may still be design/work needed to expose the extended error info in those APIs using HTTP to call the sidecar, where the sidecar returns such info

  • While the extended error info mechansim allows any number of details to be returned, in practice, the Dapr runtime only returns, at most, a single detail of each type. Given that, would it make sense to explicitly expose properties on the root object for each known type? Doing so could

     public sealed record DaprExtendedErrorInfo(int Code, string Message)
     {
         public DaprExtendedErrorDetail[] Details { get; init; } = Array.Empty<DaprExtendedErrorDetail>();
         
         public DaprBadRequestDetail? BadRequest => Details.OfType<DaprBadRequestDetail>().FirstOrDefault();
    
         public DaprErrorInfoDetail? ErrorInfo => Details.OfType<DaprErrorInfoDetail>().FirstOrDefault();
    
         public DaprHelpDetail? Help => Details.OfType<DaprHelpDetail>().FirstOrDefault();
    
         public DaprResourceInfoDetail? ResourceInfo => Details.OfType<DaprResourceInfoDetail>().FirstOrDefault();
     } 

@jev-e
Copy link
Contributor

jev-e commented Dec 8, 2024

Hi @philliphoff,

is this something that still needs implementing and would be good for a first time contributor?

@jev-e
Copy link
Contributor

jev-e commented Dec 9, 2024

/assign

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
good first issue Good for newcomers help wanted Extra attention is needed kind/enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

2 participants