Skip to content

Commit

Permalink
fix: wrap error in the error object in response
Browse files Browse the repository at this point in the history
BREAKING CHANGE: HTTP error response format is changed
  • Loading branch information
efirs committed May 31, 2022
1 parent e801479 commit beb6fc6
Show file tree
Hide file tree
Showing 42 changed files with 543 additions and 399 deletions.
2 changes: 1 addition & 1 deletion api/proto
Submodule proto updated from 8fa580 to 23ca1e
300 changes: 254 additions & 46 deletions api/server/v1/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,98 +17,306 @@ package api
import (
"encoding/json"
"fmt"
"time"

"github.com/golang/protobuf/ptypes/any"
"github.com/golang/protobuf/proto"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"google.golang.org/genproto/googleapis/rpc/errdetails"
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/durationpb"
)

// This file contains helpers for error handling
//
// 1. GRPC interface
// We reuse GRPCs standard payloads to propagate extended error information:
// https://cloud.google.com/apis/design/errors
// Our extended error code is passed in ErrorInfo and automatically unmarshalled
// on the client.
// The flow:
// * Server uses `api.Errorf({tigris code}, ...)` to report a TigrisError
// * TigrisError implements `GRPCStatus()` interface, so GRPC code can construct a GRPC error out of it
// * Client code calls `FromStatusError` to reconstruct TigrisError from GRPC status and it's payloads
// (Extended code is taken from errdetails.ErrorInfo and retry delay from errdetails.RetryInfo)
//
// 2. HTTP interface
// So as HTTP interface is intended to be inspected by users, we marshal HTTP errors
// in a more human-readable form, less verbose then GRPC errors.
//
// Example HTTP error:
// {
// "error": {
// "code": "ALREADY_EXISTS"
// "message": "database already exists"
// "retry": {
// "delay" : "1s"
// }
// }
// }
//
// The flow:
// * Server uses `api.Errorf({tigris code}, ...)` to report a TigrisError
// * We provide TigrisError.As(*runtime.HTTPStatusError) to be able to override HTTP
// status code in GRPC gateway if need to
// * The error is converted to GRPC status by GRPCStatus() interface
// * We setup (see CustomMarshaller) and provide custom marshaller of
// the GRPC status (see MarshalStatus) in GRPC gateway
// * Client parses the error using UnmarshalStatus into TigrisError

// TigrisError is our Tigris HTTP counterpart of grpc status. All the APIs should use this Error to return as a user facing
// error. TigrisError will return grpcStatus to the muxer so that grpc client can see grpcStatus as the final output. For
// HTTP clients see the **MarshalStatus** method where we are returning the response by not the grpc code as that is not
// needed for HTTP clients.
type TigrisError struct {
// The status code, which should be an enum value of [google.rpc.Code][google.rpc.Code]. We don't need to marshal
// this code for HTTP clients.
Code codes.Code `json:"code,omitempty"`
// Contains Tigris extended error code.
// Codes upto Code_UNAUTHENTICATED are identical to GRPC error codes
Code Code `json:"code,omitempty"`
// A developer-facing error message.
Message string `json:"message,omitempty"`
// A list of messages that carry the error details. This is mainly to send our internal codes and messages.
Details []*ErrorDetails `json:"details,omitempty"`

// Contains extended error information.
// For example retry information.
Details []proto.Message `json:"details,omitempty"`
}

// Error to return the underlying error message
func (e *TigrisError) Error() string {
return e.Message
}

// GRPCStatus converts the TigrisError and return status.Status. This is used to return grpc status to the grpc clients
func (e *TigrisError) GRPCStatus() *status.Status {
s := &spb.Status{
Code: int32(e.Code),
Message: e.Message,
}
// WithDetails a helper method for adding details to the TigrisError
func (e *TigrisError) WithDetails(details ...proto.Message) *TigrisError {
e.Details = append(e.Details, details...)
return e
}

if len(e.Details) == 0 {
return status.FromProto(s)
// WithRetry attached retry information to the error
func (e *TigrisError) WithRetry(d time.Duration) *TigrisError {
if d != 0 {
e.Details = append(e.Details, &errdetails.RetryInfo{RetryDelay: durationpb.New(d)})
}
return e
}

// RetryDelay retrieves retry delay if it's attached to the error
func (e *TigrisError) RetryDelay() time.Duration {
var dur time.Duration

var details []*any.Any
for _, d := range e.Details {
var a = &any.Any{}
err := anypb.MarshalFrom(a, d, proto.MarshalOptions{})
if err == nil {
details = append(details, a)
switch t := d.(type) {
case *errdetails.RetryInfo:
dur = t.RetryDelay.AsDuration()
}
}

s.Details = details
return status.FromProto(s)
return dur
}

// WithDetails a helper method for adding details to the TigrisError
func (e *TigrisError) WithDetails(details ...*ErrorDetails) *TigrisError {
e.Details = details
return e
// ToGRPCCode converts Tigris error code to GRPC code
// Extended codes converted to 'Unknown' GRPC code
func ToGRPCCode(code Code) codes.Code {
if code <= Code_UNAUTHENTICATED {
return codes.Code(code)
}

// Cannot be represented by GRPC codes
return codes.Unknown
}

// ToTigrisCode converts GRPC code to Tigris code
func ToTigrisCode(code codes.Code) Code {
return Code(code)
}

// CodeFromString parses Tigris error code from its string representation
func CodeFromString(c string) Code {
code, ok := Code_value[c]
if !ok {
return Code_UNKNOWN
}

return Code(code)
}

// CodeToString convert Tigris error code into string representation
func CodeToString(c Code) string {
r, ok := Code_name[int32(c)]
if !ok {
r = Code_name[int32(Code_UNKNOWN)]
}

return r
}

// ToHTTPCode converts Tigris' code to HTTP status code
// Used to customize HTTP codes returned by GRPC-gateway
func ToHTTPCode(code Code) int {
switch code {
case Code_OK:
return 200
case Code_CANCELLED:
return 499
case Code_UNKNOWN:
return 500
case Code_INVALID_ARGUMENT:
return 400
case Code_DEADLINE_EXCEEDED:
return 504
case Code_NOT_FOUND:
return 404
case Code_ALREADY_EXISTS:
return 409
case Code_PERMISSION_DENIED:
return 403
case Code_RESOURCE_EXHAUSTED:
return 429
case Code_FAILED_PRECONDITION:
return 412
case Code_ABORTED:
return 409
case Code_OUT_OF_RANGE:
return 400
case Code_UNIMPLEMENTED:
return 501
case Code_INTERNAL:
return 500
case Code_UNAVAILABLE:
return 503
case Code_DATA_LOSS:
return 500
case Code_UNAUTHENTICATED:
return 401

// Extended codes
case Code_CONFLICT:
return 409
case Code_BAD_GATEWAY:
return 502
}

return 500
}

// As is used by runtime.DefaultHTTPErrorHandler to override HTTP status code
func (e *TigrisError) As(i any) bool {
switch t := i.(type) {
case **runtime.HTTPStatusError:
*t = &runtime.HTTPStatusError{HTTPStatus: ToHTTPCode(e.Code), Err: e}
return true
}
return false
}

// GRPCStatus converts the TigrisError and return status.Status. This is used to return grpc status to the grpc clients
func (e *TigrisError) GRPCStatus() *status.Status {
st, _ := status.New(ToGRPCCode(e.Code), e.Message).
WithDetails(&errdetails.ErrorInfo{Reason: CodeToString(e.Code)})

if e.Details != nil {
st, _ = st.WithDetails(e.Details...)
}

return st
}

// MarshalStatus marshal status object
func MarshalStatus(status *spb.Status) ([]byte, error) {
var resp = &TigrisError{}
resp.Message = status.Message
resp.Code = codes.Code(status.Code)
if len(status.Details) > 0 {
var internalDetails []*ErrorDetails
for _, d := range status.Details {
var ed = &ErrorDetails{}
err := anypb.UnmarshalTo(d, ed, proto.UnmarshalOptions{})
resp := struct {
Error ErrorDetails `json:"error"`
}{}

resp.Error.Message = status.Message
// Get standard GRPC code first
resp.Error.Code = Code_name[int32(ToTigrisCode(codes.Code(status.Code)))]

// Get extended Tigris code if it's attached to the details
for _, d := range status.Details {
var ei errdetails.ErrorInfo
if d.MessageIs(&ei) {
err := d.UnmarshalTo(&ei)
if err != nil {
return nil, err
}
internalDetails = append(internalDetails, ed)
resp.Error.Code = ei.Reason
}
var ri errdetails.RetryInfo
if d.MessageIs(&ri) {
err := d.UnmarshalTo(&ri)
if err != nil {
return nil, err
}
resp.Error.Retry = &RetryInfo{
Delay: int32(ri.RetryDelay.AsDuration().Milliseconds()),
}
}
resp.Details = internalDetails
}

return json.Marshal(resp)
return json.Marshal(&resp)
}

// Errorf returns Error(c, fmt.Sprintf(format, a...)).
func Errorf(c codes.Code, format string, a ...interface{}) *TigrisError {
return Error(c, fmt.Sprintf(format, a...))
// FromErrorDetails construct TigrisError from the ErrorDetails,
// which contains extended code, retry information, etc...
func FromErrorDetails(e *ErrorDetails) *TigrisError {
var te TigrisError

c, ok := Code_value[e.Code]
if !ok {
c = int32(Code_UNKNOWN)
}

te.Code = Code(c)
te.Message = e.Message

if e.Retry == nil {
return &te
}

return te.WithRetry(time.Duration(e.Retry.Delay) * time.Millisecond)
}

// UnmarshalStatus reconstruct TigrisError from HTTP error JSON body
func UnmarshalStatus(b []byte) *TigrisError {
resp := struct {
Error ErrorDetails `json:"error"`
}{}

if err := json.Unmarshal(b, &resp); err != nil {
return &TigrisError{Code: Code_UNKNOWN, Message: err.Error()}
}

return FromErrorDetails(&resp.Error)
}

// Error returns an error representing c and msg. If c is OK, returns nil.
func Error(c codes.Code, msg string) *TigrisError {
if c == codes.OK {
// FromStatusError parses GRPC status from error into TigrisError
func FromStatusError(err error) *TigrisError {
st := status.Convert(err)
code := ToTigrisCode(st.Code())

var details []proto.Message
for _, v := range st.Details() {
switch d := v.(type) {
case *errdetails.ErrorInfo:
code = CodeFromString(d.Reason)
case *errdetails.RetryInfo:
details = append(details, &errdetails.RetryInfo{RetryDelay: d.RetryDelay})
}
}

return &TigrisError{Code: code, Message: st.Message(), Details: details}
}

// Errorf constructs TigrisError
func Errorf(c Code, format string, a ...interface{}) *TigrisError {
if c == Code_OK {
return nil
}

return &TigrisError{
e := &TigrisError{
Code: c,
Message: msg,
Message: fmt.Sprintf(format, a...),
}

return e
}
13 changes: 11 additions & 2 deletions api/server/v1/marshaler.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,22 @@ import (
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
jsoniter "github.com/json-iterator/go"
spb "google.golang.org/genproto/googleapis/rpc/status"
"google.golang.org/protobuf/proto"
)

// CustomMarshaler is a marshaler to customize the response. Currently it is only used to marshal custom error message
// otherwise it just use the inbuilt mux marshaller.
// CustomMarshaler is a marshaler to customize the response. Currently, it is only used to marshal custom error message
// otherwise it just uses the inbuilt mux marshaller.
type CustomMarshaler struct {
*runtime.JSONBuiltin
}

func (c *CustomMarshaler) Marshal(v interface{}) ([]byte, error) {
switch ty := v.(type) {
case map[string]proto.Message:
// this comes from GRPC-gateway streaming code
if e, ok := ty["error"]; ok {
return MarshalStatus(e.(*spb.Status))
}
case *spb.Status:
return MarshalStatus(ty)
case *ListCollectionsResponse:
Expand Down Expand Up @@ -74,6 +80,9 @@ type Metadata struct {

func CreateMDFromResponseMD(x *ResponseMetadata) Metadata {
var md Metadata
if x == nil {
return md
}
if x.CreatedAt != nil {
tm := x.CreatedAt.AsTime()
md.CreatedAt = &tm
Expand Down
Loading

0 comments on commit beb6fc6

Please sign in to comment.