Repository: yarpc/yab
Pull request #340 (2021-05-13 19:04:27)

New: print proto error details if any in the returned error
Error details are now printed along the protobuf error if there are any.

For example you will get the following:
```
Failed while making call: code:internal message:this is an expected error
{
  "details": [
    {
      "type.googleapis.com/uber.infra.net.rpc.probe.EchoErrorDetail": {
        "payload": {
          "pongInt32": 1
        }
      }
    }
  ]
}
```

If no error details are returned, the output will remain the same as before, for instance:
```
Failed while making call: code:internal message:this is an expected error
```

codecov[bot]: # [Codecov](https://codecov.io/gh/yarpc/yab/pull/340?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yarpc) Report
> Merging [#340](https://codecov.io/gh/yarpc/yab/pull/340?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yarpc) (4f3e7fe) into [dev](https://codecov.io/gh/yarpc/yab/commit/83c3e197184fa13cd54d2c08650c5cf02c672ae9?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yarpc) (83c3e19) will **decrease** coverage by `0.07%`.
> The diff coverage is `77.41%`.

[![Impacted file tree graph](https://codecov.io/gh/yarpc/yab/pull/340/graphs/tree.svg?width=650&height=150&src=pr&token=yEgrqUTCuh&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yarpc)](https://codecov.io/gh/yarpc/yab/pull/340?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yarpc)

```diff
@@            Coverage Diff             @@
##              dev     #340      +/-   ##
==========================================
- Coverage   90.41%   90.33%   -0.08%     
==========================================
  Files          54       54              
  Lines        2671     2701      +30     
==========================================
+ Hits         2415     2440      +25     
- Misses        180      183       +3     
- Partials       76       78       +2     
```


| [Impacted Files](https://codecov.io/gh/yarpc/yab/pull/340?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yarpc) | Coverage Δ | |
|---|---|---|
| [encoding/encoding.go](https://codecov.io/gh/yarpc/yab/pull/340/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yarpc#diff-ZW5jb2RpbmcvZW5jb2RpbmcuZ28=) | `90.90% <ø> (ø)` | |
| [main.go](https://codecov.io/gh/yarpc/yab/pull/340/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yarpc#diff-bWFpbi5nbw==) | `91.63% <71.42%> (-1.29%)` | :arrow_down: |
| [encoding/protobuf.go](https://codecov.io/gh/yarpc/yab/pull/340/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yarpc#diff-ZW5jb2RpbmcvcHJvdG9idWYuZ28=) | `91.33% <82.35%> (-1.39%)` | :arrow_down: |
| [handler.go](https://codecov.io/gh/yarpc/yab/pull/340/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yarpc#diff-aGFuZGxlci5nbw==) | `83.70% <0.00%> (+1.12%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/yarpc/yab/pull/340?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yarpc).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yarpc)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/yarpc/yab/pull/340?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yarpc). Last update [83c3e19...4f3e7fe](https://codecov.io/gh/yarpc/yab/pull/340?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yarpc). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yarpc).


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4fa08b7..b641f04 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,9 @@
 Changelog
 =========
 
+# Unreleased
+* New: gRPC details are now printed along with the error if there are any.
+
 # 0.19.1 (2021-04-02)
 * Fix byte parsing to allow 8-bit signed integers to match the Thrift spec & other language implementations.
 
diff --git a/encoding/encoding.go b/encoding/encoding.go
index 571cdbe..086e624 100644
--- a/encoding/encoding.go
+++ b/encoding/encoding.go
@@ -69,6 +69,13 @@ type Serializer interface {
 	MethodType() MethodType
 }
 
+// ProtoErrorDeserializer deserializes errors.
+// Error details are very specific to proto.
+type ProtoErrorDeserializer interface {
+	// Error converts an error into something that can be displayed to a user.
+	ErrorDetails(err error) ([]interface{}, error)
+}
+
 // StreamRequestReader interface exposes method to read multiple request body
 type StreamRequestReader interface {
 	// NextBody returns the encoded request body if available, and if not, returns an
diff --git a/encoding/protobuf.go b/encoding/protobuf.go
index 556e886..2ac9dc3 100644
--- a/encoding/protobuf.go
+++ b/encoding/protobuf.go
@@ -17,6 +17,8 @@ import (
 	"github.com/jhump/protoreflect/desc"
 	"github.com/jhump/protoreflect/dynamic"
 	"go.uber.org/yarpc/pkg/procedure"
+	"go.uber.org/yarpc/yarpcerrors"
+	"google.golang.org/genproto/googleapis/rpc/status"
 )
 
 type protoSerializer struct {
@@ -106,6 +108,49 @@ func (p protoSerializer) Encoding() Encoding {
 	return Protobuf
 }
 
+func (p protoSerializer) ErrorDetails(err error) ([]interface{}, error) {
+	// Here we use yarpcerrors since the transport layer of yab is using yarpc as well
+	if !yarpcerrors.IsStatus(err) {
+		return nil, nil
+	}
+
+	yerr := yarpcerrors.FromError(err)
+	if len(yerr.Details()) == 0 {
+		return nil, nil
+	}
+
+	errStatus := &status.Status{}
+	if err := proto.Unmarshal(yerr.Details(), errStatus); err != nil {
+		return nil, fmt.Errorf("could not unmarshal error details %s", err.Error())
+	}
+
+	details := []interface{}{}
+	for _, detail := range errStatus.Details {
+		// By default we set to the value of the proto detail message to its byte values.
+		// It is possible that YAB will not be able to resolve the type message.
+		// This can happen when an error is being bubbled up in a chain of services.
+		// For instance, let's say we have A -> B -> C.
+		// If A does not have registered detail messages descriptors from C and B blindly bubbled up
+		// errors from C, YAB will not be able to resolve the type of the details based
+		// on the descriptors given by A (through the reflection server).
+		var value interface{} = detail.Value
+
+		rdetail, rerr := p.anyResolver.Resolve(detail.TypeUrl)
+		if rerr == nil {
+			if err := proto.Unmarshal(detail.Value, rdetail); err != nil {
+				return nil, fmt.Errorf("could not unmarshal error detail message %s", err.Error())
+			}
+			value = rdetail
+		}
+
+		details = append(details, map[string]interface{}{
+			detail.TypeUrl: value,
+		})
+	}
+
+	return details, nil
+}
+
 func (p protoSerializer) MethodType() MethodType {
 	if p.method.IsClientStreaming() && p.method.IsServerStreaming() {
 		return BidirectionalStream
diff --git a/go.mod b/go.mod
index 8285e3a..bc5b4c1 100644
--- a/go.mod
+++ b/go.mod
@@ -43,6 +43,7 @@ require (
 	go.uber.org/zap v1.10.0
 	golang.org/x/net v0.0.0-20190926025831-c00fd9afed17
 	golang.org/x/text v0.3.1-0.20180511172408-5c1cf69b5978 // indirect
+	google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8 // indirect
 	google.golang.org/grpc v1.24.0
 	gopkg.in/yaml.v2 v2.2.2
 )
diff --git a/main.go b/main.go
index 6df616c..58a0584 100644
--- a/main.go
+++ b/main.go
@@ -21,6 +21,7 @@
 package main
 
 import (
+	"bytes"
 	"context"
 	"encoding/json"
 	"errors"
@@ -472,7 +473,26 @@ func makeContextWithTrace(ctx context.Context, t transport.Transport, request *t
 func makeInitialRequest(out output, transport transport.Transport, serializer encoding.Serializer, req *transport.Request) {
 	response, err := makeRequestWithTracePriority(transport, req, 1)
 	if err != nil {
-		out.Fatalf("Failed while making call: %v\n", err)
+		buffer := bytes.NewBufferString(err.Error())
+
+		if errorSerializer, ok := serializer.(encoding.ProtoErrorDeserializer); ok {
+			details, derr := errorSerializer.ErrorDetails(err)
+			if derr != nil {
+				out.Fatalf("Failed to get protobuf error details %s", derr.Error())
+			}
+
+			if len(details) > 0 {
+				bs, merr := json.MarshalIndent(map[string]interface{}{"details": details}, "", "  ")
+				if merr != nil {
+					out.Fatalf("Failed to convert protobuf error details to JSON: %v\nMap: %+v\n", merr, details)
+				}
+				buffer.WriteString("\n")
+				buffer.Write(bs)
+				buffer.WriteString("\n")
+			}
+		}
+
+		out.Fatalf("Failed while making call: %s\n", buffer.String())
 	}
 
 	// responseMap converts the Thrift bytes response to a map.


Test patch:
diff --git a/encoding/protobuf_test.go b/encoding/protobuf_test.go
index 0dbb55b..67c4ce9 100644
--- a/encoding/protobuf_test.go
+++ b/encoding/protobuf_test.go
@@ -19,6 +19,8 @@ import (
 	"github.com/jhump/protoreflect/dynamic"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"go.uber.org/yarpc/yarpcerrors"
+	"google.golang.org/genproto/googleapis/rpc/status"
 )
 
 func TestNewProtobuf(t *testing.T) {
@@ -159,6 +161,138 @@ func TestProtobufRequest(t *testing.T) {
 	}
 }
 
+func TestProtobufErrorDetails(t *testing.T) {
+	source, err := protobuf.NewDescriptorProviderFileDescriptorSetBins("../testdata/protobuf/simple/simple.proto.bin")
+	require.NoError(t, err)
+	anySource, err := protobuf.NewDescriptorProviderFileDescriptorSetBins("../testdata/protobuf/any/any.proto.bin")
+	require.NoError(t, err)
+
+	grpcStatusBytesFunc := func(typeUrl string, msg proto.Message) []byte {
+		valueContent, err := proto.Marshal(msg)
+		require.NoError(t, err)
+
+		a := &any.Any{
+			TypeUrl: typeUrl,
+			Value:   valueContent,
+		}
+
+		s := &status.Status{}
+		s.Details = []*any.Any{a}
+		bytes, err := proto.Marshal(s)
+		require.NoError(t, err)
+		return bytes
+	}
+
+	grpcStatusCorrupedBytesFunc := func(typeUrl string, msg proto.Message) []byte {
+		valueContent, err := proto.Marshal(msg)
+		require.NoError(t, err)
+
+		a := &any.Any{
+			TypeUrl: typeUrl,
+			Value:   valueContent[:1],
+		}
+
+		s := &status.Status{}
+		s.Details = []*any.Any{a}
+		bytes, err := proto.Marshal(s)
+		require.NoError(t, err)
+		return bytes
+	}
+
+	tests := []struct {
+		desc          string
+		method        string
+		source        protobuf.DescriptorProvider
+		err           error
+		wantOutAsJSON string
+		wantErr       error
+	}{
+		{
+			desc:          "nil error",
+			source:        source,
+			method:        "Bar/Baz",
+			err:           nil,
+			wantErr:       nil,
+			wantOutAsJSON: "null",
+		},
+		{
+			desc:          "std error",
+			source:        source,
+			method:        "Bar/Baz",
+			err:           fmt.Errorf("this is a test error"),
+			wantErr:       nil,
+			wantOutAsJSON: "null",
+		},
+		{
+			desc:          "yarpc error with no details",
+			source:        source,
+			method:        "Bar/Baz",
+			err:           yarpcerrors.FromError(fmt.Errorf("this is a test error")),
+			wantErr:       nil,
+			wantOutAsJSON: "null",
+		},
+		{
+			desc:          "yarpc error bad details bytes",
+			source:        source,
+			method:        "Bar/Baz",
+			err:           yarpcerrors.FromError(fmt.Errorf("this is a test error")).WithDetails([]byte{0x8, 0x1, 0x12}),
+			wantErr:       fmt.Errorf("could not unmarshal error details unexpected EOF"),
+			wantOutAsJSON: "null",
+		},
+		{
+			desc:   "yarpc error with  1 detail",
+			source: anySource,
+			method: "BarAny/BazAny",
+			err: yarpcerrors.FromError(fmt.Errorf("this is a test error")).WithDetails(grpcStatusBytesFunc("type.googleapis.com/FooAny", &tany.FooAny{
+				Value: 10,
+			})),
+			wantErr:       nil,
+			wantOutAsJSON: `[{"type.googleapis.com/FooAny":{"value":10}}]`,
+		},
+		{
+			desc:   "yarpc error with detail which can not be resolved",
+			source: anySource,
+			method: "BarAny/BazAny",
+			err: yarpcerrors.FromError(fmt.Errorf("this is a test error")).WithDetails(grpcStatusBytesFunc("uri/not/identifiable", &tany.FooAny{
+				Value: 10,
+			})),
+			wantErr:       nil,
+			wantOutAsJSON: `[{"uri/not/identifiable":{"V":"CAo="}}]`,
+		},
+		{
+			desc:   "yarpc error with detail with corrupted bytes in any detail message",
+			source: anySource,
+			method: "BarAny/BazAny",
+			err: yarpcerrors.FromError(fmt.Errorf("this is a test error")).WithDetails(grpcStatusCorrupedBytesFunc("type.googleapis.com/FooAny", &tany.FooAny{
+				Value: 10,
+			})),
+			wantErr:       fmt.Errorf("could not unmarshal error detail message unexpected EOF"),
+			wantOutAsJSON: ``,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.desc, func(t *testing.T) {
+			serializer, err := NewProtobuf(tt.method, tt.source)
+			require.NoError(t, err, "Failed to create serializer")
+			errorSerializer, ok := serializer.(ProtoErrorDeserializer)
+			require.True(t, ok)
+
+			result, err := errorSerializer.ErrorDetails(tt.err)
+
+			if tt.wantErr != nil {
+				require.Error(t, err, tt.desc)
+				assert.Contains(t, err.Error(), tt.wantErr.Error(), "%v: invalid error", tt.desc)
+			} else {
+				require.NoError(t, err, tt.desc)
+				r, err := json.Marshal(result)
+				require.NoError(t, err)
+				assert.Equal(t, tt.wantOutAsJSON, string(r))
+			}
+		})
+	}
+}
+
 func TestProtobufResponse(t *testing.T) {
 	source, err := protobuf.NewDescriptorProviderFileDescriptorSetBins("../testdata/protobuf/simple/simple.proto.bin")
 	require.NoError(t, err)
diff --git a/integration_test.go b/integration_test.go
index 3c85067..f33605c 100644
--- a/integration_test.go
+++ b/integration_test.go
@@ -55,6 +55,8 @@ import (
 	ytchan "go.uber.org/yarpc/transport/tchannel"
 	"google.golang.org/grpc"
 	"google.golang.org/grpc/reflection"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
 )
 
 //go:generate thriftrw --plugin=yarpc --out ./testdata/yarpc ./testdata/integration.thrift
@@ -378,6 +380,18 @@ func (s *simpleService) Baz(c context.Context, in *simple.Foo) (*simple.Foo, err
 		return in, nil
 	}
 
+	if in.Test == -1 {
+		st := status.New(codes.InvalidArgument, "invalid username")
+		st, err := st.WithDetails(in)
+		if err != nil {
+			// If this errored, it will always error
+			// here, so better panic so we can figure
+			// out why than have this silently passing.
+			panic(fmt.Sprintf("Unexpected error: %v", err))
+		}
+		return nil, st.Err()
+	}
+
 	return nil, fmt.Errorf("negative input")
 }
 
@@ -1092,7 +1106,13 @@ func TestGRPCReflectionSource(t *testing.T) {
 					Peers:       []string{addr.String()},
 				},
 			},
-			wantRes: `"test": 1`,
+			wantRes: `{
+  "body": {
+    "test": 1
+  }
+}
+
+`,
 		},
 		{
 			desc: "success (no scheme in peer)",
@@ -1107,7 +1127,13 @@ func TestGRPCReflectionSource(t *testing.T) {
 					Peers:       []string{addr.String()},
 				},
 			},
-			wantRes: `"test": 1`,
+			wantRes: `{
+  "body": {
+    "test": 1
+  }
+}
+
+`,
 		},
 		{
 			desc: "return error",
@@ -1122,14 +1148,40 @@ func TestGRPCReflectionSource(t *testing.T) {
 					Peers:       []string{addr.String()},
 				},
 			},
-			wantErr: "negative input",
+			wantErr: "Failed while making call: code:unknown message:negative input\n",
+		},
+		{
+			desc: "return error with details",
+			opts: Options{
+				ROpts: RequestOptions{
+					Procedure:   "Bar/Baz",
+					Timeout:     timeMillisFlag(time.Second),
+					RequestJSON: `{"test":-1}`,
+				},
+				TOpts: TransportOptions{
+					ServiceName: "foo",
+					Peers:       []string{addr.String()},
+				},
+			},
+			wantErr: `Failed while making call: code:invalid-argument message:invalid username
+{
+  "details": [
+    {
+      "type.googleapis.com/Foo": {
+        "test": -1
+      }
+    }
+  ]
+}
+
+`,
 		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.desc, func(t *testing.T) {
 			gotOut, gotErr := runTestWithOpts(tt.opts)
-			assert.Contains(t, gotErr, tt.wantErr)
-			assert.Contains(t, gotOut, tt.wantRes)
+			assert.Equal(t, gotErr, tt.wantErr)
+			assert.Equal(t, gotOut, tt.wantRes)
 		})
 	}
 }