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 <jonathan.sternberg@docker.com>
  • Loading branch information
jsternberg committed Jul 22, 2024
1 parent acf19c1 commit a5367c8
Show file tree
Hide file tree
Showing 3 changed files with 225 additions and 28 deletions.
226 changes: 200 additions & 26 deletions errgrpc/grpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,17 @@ 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/types/known/anypb"
"google.golang.org/protobuf/types/known/emptypb"

"github.com/containerd/errdefs"
"github.com/containerd/errdefs/internal/cause"
Expand All @@ -43,51 +48,104 @@ 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) {
any, _ := anypb.New(p)
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()
}

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)
}
}
}

switch {
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 +156,122 @@ 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))
}
}
err = wrap(err, wrapped)
}
return err
}

// wrap will wrap errs within the parent error.
// If the parent supports errorWrapper, it will use that.
// Otherwise, it will generate the necessary structs
// to fit the structure.
func wrap(parent error, errs []error) error {
// 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.
for len(errs) > 0 {
// If errorWrapper is implemented, invoke it
// and set the new error to the returned
// value. Then return to the start of the loop.
if err, ok := parent.(errorWrapper); ok {
parent = err.WrapError(errs[0])
errs = errs[1:]
continue
}

// Create a default wrapper that conforms to
// the errors API. If there is only one wrapped
// error, use a version that supports Unwrap() error
// since there's more compatibility with that interface.
if len(errs) == 1 {
return &wrapError{
error: parent,
err: errs[0],
}
}
return &wrapErrors{
error: parent,
}
}
return parent
}

type errorWrapper interface {
// WrapError is used to include a wrapped error in the
// parent error during unmarshaling. If an error doesn't
// need direct knowledge of its wrapped error, then it
// shouldn't implement this method and should instead
// utilize the generic structure created during unmarshaling.
//
// This will return the error. It is ok if the error modifies
// and returns itself.
WrapError(err error) error
}

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()...)
}
}
}
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ go 1.20

require (
github.com/containerd/typeurl/v2 v2.1.1
google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97
google.golang.org/grpc v1.58.3
google.golang.org/protobuf v1.31.0
)

require (
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.3 // indirect
golang.org/x/sys v0.13.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97 // indirect
google.golang.org/protobuf v1.31.0 // indirect
)

0 comments on commit a5367c8

Please sign in to comment.