Skip to content

Commit

Permalink
Add support for encoding error structure for grpc errors
Browse files Browse the repository at this point in the history
This adds support for encoding the error structure for grpc errors. It
does this by using the details to encode any wrapped errors.

Each error is composed of a `Status` protobuf. The message included in
that status is the default error message if no additional details are
provided. The first detail is an encoding of the error if one was given.
If there was no encoding possible or it wasn't possible to deserialize
it, a generic error is created with the original message.

Any additional details are any wrapped errors. Each wrapped error is a
`Status` that follows the same encoding scheme. This allows an error to
wrap multiple errors and the implementation isn't dependent on the
contents.

Signed-off-by: Jonathan A. Sternberg <[email protected]>
  • Loading branch information
jsternberg committed Jul 22, 2024
1 parent acf19c1 commit 2f04932
Show file tree
Hide file tree
Showing 2 changed files with 201 additions and 26 deletions.
204 changes: 178 additions & 26 deletions errgrpc/grpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,18 @@ package errgrpc

import (
"context"
"errors"
"fmt"
"strconv"
"strings"

"github.com/containerd/typeurl/v2"
spb "google.golang.org/genproto/googleapis/rpc/status"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
"google.golang.org/protobuf/types/known/emptypb"

"github.com/containerd/errdefs"
"github.com/containerd/errdefs/internal/cause"
Expand All @@ -43,51 +49,108 @@ import (
// If the error is unmapped, the original error will be returned to be handled
// by the regular grpc error handling stack.
func ToGRPC(err error) error {
if err == nil {
return nil
if err == nil || isGRPCError(err) {
return err
}

if isGRPCError(err) {
// error has already been mapped to grpc
return err
p := &spb.Status{
Code: int32(errorCode(err)),
Message: err.Error(),
}
withDetails(p, err)
return status.FromProto(p).Err()
}

func withDetails(p *spb.Status, err error) {
var any *anypb.Any
if m, ok := err.(proto.Message); ok {
any, _ = anypb.New(m)
}

if any == nil {
// If we fail to marshal the details, then use a generic
// error by setting this as the empty struct.
any, _ = anypb.New(&emptypb.Empty{})
}

if any == nil {
// Extra protection just in case the above fails for
// some reason.
return
}

// First detail is a serialization of the current error.
p.Details = append(p.Details, &anypb.Any{
TypeUrl: any.GetTypeUrl(),
Value: any.GetValue(),
})

// Any remaining details are wrapped errors. We check
// both versions of Unwrap to get this correct.
var errs []error
switch err := err.(type) {
case interface{ Unwrap() error }:
if unwrapped := err.Unwrap(); unwrapped != nil {
errs = []error{unwrapped}
}
case interface{ Unwrap() []error }:
errs = err.Unwrap()
}

switch {
for _, err := range errs {
detail := &spb.Status{
// Code doesn't matter. We don't use it beyond the top level.
// Set to unknown just in case it leaks somehow.
Code: int32(codes.Unknown),
Message: err.Error(),
}
withDetails(detail, err)

if any, err := anypb.New(detail); err == nil {
p.Details = append(p.Details, any)
}
}
}

func errorCode(err error) codes.Code {
switch err := errdefs.Resolve(err); {
case errdefs.IsInvalidArgument(err):
return status.Error(codes.InvalidArgument, err.Error())
return codes.InvalidArgument
case errdefs.IsNotFound(err):
return status.Error(codes.NotFound, err.Error())
return codes.NotFound
case errdefs.IsAlreadyExists(err):
return status.Error(codes.AlreadyExists, err.Error())
case errdefs.IsFailedPrecondition(err) || errdefs.IsConflict(err) || errdefs.IsNotModified(err):
return status.Error(codes.FailedPrecondition, err.Error())
return codes.AlreadyExists
case errdefs.IsFailedPrecondition(err):
fallthrough
case errdefs.IsConflict(err):
fallthrough
case errdefs.IsNotModified(err):
return codes.FailedPrecondition
case errdefs.IsUnavailable(err):
return status.Error(codes.Unavailable, err.Error())
return codes.Unavailable
case errdefs.IsNotImplemented(err):
return status.Error(codes.Unimplemented, err.Error())
return codes.Unimplemented
case errdefs.IsCanceled(err):
return status.Error(codes.Canceled, err.Error())
return codes.Canceled
case errdefs.IsDeadlineExceeded(err):
return status.Error(codes.DeadlineExceeded, err.Error())
return codes.DeadlineExceeded
case errdefs.IsUnauthorized(err):
return status.Error(codes.Unauthenticated, err.Error())
return codes.Unauthenticated
case errdefs.IsPermissionDenied(err):
return status.Error(codes.PermissionDenied, err.Error())
return codes.PermissionDenied
case errdefs.IsInternal(err):
return status.Error(codes.Internal, err.Error())
return codes.Internal
case errdefs.IsDataLoss(err):
return status.Error(codes.DataLoss, err.Error())
return codes.DataLoss
case errdefs.IsAborted(err):
return status.Error(codes.Aborted, err.Error())
return codes.Aborted
case errdefs.IsOutOfRange(err):
return status.Error(codes.OutOfRange, err.Error())
return codes.OutOfRange
case errdefs.IsResourceExhausted(err):
return status.Error(codes.ResourceExhausted, err.Error())
case errdefs.IsUnknown(err):
return status.Error(codes.Unknown, err.Error())
return codes.ResourceExhausted
default:
return codes.Unknown
}

return err
}

// ToGRPCf maps the error to grpc error codes, assembling the formatting string
Expand All @@ -98,6 +161,95 @@ func ToGRPCf(err error, format string, args ...interface{}) error {
return ToGRPC(fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), err))
}

func FromGRPC(err error) error {
if err == nil {
return nil
}

st, ok := status.FromError(err)
if !ok {
return err
}

p := st.Proto()
return fromGRPCProto(p)
}

func fromGRPCProto(p *spb.Status) error {
err := errors.New(p.Message)
if len(p.Details) == 0 {
return err
}

// First detail has the serialization.
detail := p.Details[0]
v, terr := typeurl.UnmarshalAny(detail)
if terr == nil {
if verr, ok := v.(error); ok {
// Successfully unmarshaled the type as an error.
// Use this instead.
err = verr
}
}

// If there is more than one detail, attempt to unmarshal
// each one of them.
if len(p.Details) > 1 {
wrapped := make([]error, 0, len(p.Details)-1)
for _, detail := range p.Details[1:] {
p, derr := typeurl.UnmarshalAny(detail)
if derr != nil {
continue
}

switch p := p.(type) {
case *spb.Status:
wrapped = append(wrapped, fromGRPCProto(p))
}
}

// If the error supports WrapError, then use that
// to modify the error to include the wrapped error.
// Otherwise, we create a proxy type so Unwrap works.
if wrapper, ok := err.(interface{ WrapError(err error) }); ok {
for _, w := range wrapped {
wrapper.WrapError(w)
}
} else {
if len(wrapped) == 1 {
err = &wrapError{
error: err,
err: wrapped[0],
}
} else {
err = &wrapErrors{
error: err,
errs: wrapped,
}
}
}
}
return err
}

type wrapError struct {
error
err error
}

func (e *wrapError) Unwrap() error {
return e.err
}

type wrapErrors struct {
error
errs []error
}

func (e *wrapErrors) Unwrap() []error {
return e.errs
}

// ToNative returns the underlying error from a grpc service based on the grpc error code
func ToNative(err error) error {
if err == nil {
Expand Down
23 changes: 23 additions & 0 deletions errgrpc/grpc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,26 @@ func TestGRPCRoundTrip(t *testing.T) {
})
}
}

func TestGRPC(t *testing.T) {
err := fmt.Errorf("my error: %w", errdefs.ErrAborted)
gerr := ToGRPC(err)
st, _ := status.FromError(gerr)
p := st.Proto()
fmt.Printf("%d\n", len(p.Details))
ferr := FromGRPC(gerr)

for unvisited := []error{ferr}; len(unvisited) > 0; {
cur := unvisited[0]
unvisited = unvisited[1:]
fmt.Printf("%s %T\n", cur.Error(), cur)
switch cur := cur.(type) {
case interface{ Unwrap() error }:
if v := cur.Unwrap(); v != nil {
unvisited = append(unvisited, v)
}
case interface{ Unwrap() []error }:
unvisited = append(unvisited, cur.Unwrap()...)
}
}
}

0 comments on commit 2f04932

Please sign in to comment.