grpc与http的错误传递 https://mp.weixin.qq.com/s/xZTEzPcqQl77NNGotqNTUA

grpc与http的错误传递

原创 王宇 学而思网校技术团队 2022-05-27 18:20 发表于北京

✦点击蓝字 关注我们

使用pb文件定义接口后, 通过 grpc 和 现有http业务 使用同一个pb结构, 发现他们结构之间是有差异的.

grpc中通常使用 error 返回值判断成功或失败, 而我们对 http 请求更习惯使用 body 体的 stat 来判断.

例如定义了如下pb结构

message GenerateTaskReply {
   string name = 1;
   uint32 age = 2;
}

grpc的返回值

grpc  success

{
  "name" : "小明",
  "age"  : 12

grpc fail

nil,   error:[stat:code.Unknown, msg:"失败了"]

http 返回值

由于pb文件通常不会再记录 服务异常状态, 格式如下:

http 200

{
  "name" : "小明",
  "age"  : 12

http 500

{
  "code": 500,
  "message": "service error"
  "reason" : "ERROR_REASON",
}

而在我们现有系统中,  格式如下:

http code: 200

{
  "stat": 1,
  "msg" : "success",
  "data": {
     "name" : "小明",
     "age"  : 12
  }
}

http code: 200

{
  "stat": 10000,
  "msg" : "失败了",
  "data": {}
}

我们通过上面返回值看到, grpc 与 http 在错误判断上的不同, grpc是通过 error  判断,  而 http 是基于 body 体返回值做的判断, 而我们返回值 body 是使用pb管理, 对于grpc来说 错误是通过error来管理的, 这样就带来了无法共用的问题

对于grpc来说可以冗余字段设计的, 但是对于grpc 客户端是比较困惑的, 需要做额外的逻辑判断, 一般不建议使用此方式.

由此问题进行调研学习, 进行一个记录

  1. grpc 的错误传递
  2. grpc code码含义
  3. http 错误怎么做

1. grpc 错误传递

假如 server 端代码如下,  返回了标准的 error 错误

func (s *Server) SayHello(ctx context.Context, t *code.Request) (*code.Response, error) {
   return &code.Response{Msg: "Hello world"}, errors.New("query db err, id=1: record not found")
}

client通过 grpc 调用, 返回对应的 response 和 error两个值

    r, err := client.SayHello(ctx, &code.Request{})
    if err != nil {
        fmt.Printf("%+v\n", err) //rpc error: code = Unknown desc = query db err, id=1: record not found
        grpcErr, _ := status.FromError(err)
        fmt.Printf("%d\n", grpcErr.Code()) // Unknown code = 2
        fmt.Printf("%+v\n", grpcErr.Message()) //query db err, id=1: record not found
    } else {
        fmt.Printf("%+v\n", r)
    }

以上会打印错误,  因为我们使用的非标准 grpc 错误结构, 所以解码出来 grpc code 码为 Unknown, message是我们服务端返回的错误原因

grpc 提供了定义错误的方法, 第一个参数是可以指定 grpc code 的, 底层创建了 grpc 内置的 Status 对象

status.Error(codes.Canceled, "query db err, id=1: record not found")

grpc Status定义的错误结构如下:

type Status struct {
    state         protoimpl.MessageState
    sizeCache     protoimpl.SizeCache
    unknownFields protoimpl.UnknownFields

    // The status code, which should be an enum value of [google.rpc.Code][google.rpc.Code].
    Code int32 `protobuf:"varint,1,opt,name=code,proto3" json:"code,omitempty"`
    // A developer-facing error message, which should be in English. Any
    // user-facing error message should be localized and sent in the
    // [google.rpc.Status.details][google.rpc.Status.details] field, or localized by the client.
    Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
    // A list of messages that carry the error details.  There is a common set of
    // message types for APIs to use.
    Details []*any.Any `protobuf:"bytes,3,rep,name=details,proto3" json:"details,omitempty"`
}

这个结构下文将反复提及, 描述为 Status 结构

Code 和 Message 上述案例中已涉及到了, Details 定义的是 any.Any 类型, 我们可以灵活的挂载一些其他自定义信息

通过上面例子, 我们知道了 grpc 如何发送接收错误了, 那么是如何传递错误的呢?

先上结论:  grpc 传输是基于 http2 协议的, 错误传输是基于其 header 头来做的

下面我们看下 grpc-status、grpc-message 以及 grpc-status-details-bin 这几个头信息

grpc 服务端当执行 status.Error(codes.Canceled, "query db err, id=1: record not found") 时, 将映射到对应header头上面.

grpc@v1.45.0/internal/transport/http2_server.go:1011

//....
//st就是Status结构对象, 
headerFields = append(headerFields, hpack.HeaderField{Name: "grpc-status", Value: strconv.Itoa(int(st.Code()))})
headerFields = append(headerFields, hpack.HeaderField{Name: "grpc-message", Value: encodeGrpcMessage(st.Message())})

//如果包含details定义, 则进行序列化并base64编码
if p := st.Proto(); p != nil && len(p.Details) > 0 {
  stBytes, err := proto.Marshal(p)
  if err != nil {
    // TODO: return error instead, when callers are able to handle it.
    logger.Errorf("transport: failed to marshal rpc status: %v, error: %v", p, err)
  } else {
    headerFields = append(headerFields, hpack.HeaderField{Name: "grpc-status-details-bin", Value: encodeBinHeader(stBytes)})
  }
}
//...

grpc客户端接收header头, 将错误重新构造成 Status 结构

grpc@v1.45.0/internal/transport/http2_client.go:1321

for _, hf := range frame.Fields {
        switch hf.Name {
        //...
        case "grpc-status":
            code, err := strconv.ParseInt(hf.Value, 10, 32)
            if err != nil {
                se := status.New(codes.Internal, fmt.Sprintf("transport: malformed grpc-status: %v", err))
                t.closeStream(s, se.Err(), true, http2.ErrCodeProtocol, se, nil, endStream)
                return
            }
            rawStatusCode = codes.Code(uint32(code))
        case "grpc-message":
            grpcMessage = decodeGrpcMessage(hf.Value)
        case "grpc-status-details-bin":
            var err error
            statusGen, err = decodeGRPCStatusDetails(hf.Value)
            if err != nil {
                headerError = fmt.Sprintf("transport: malformed grpc-status-details-bin: %v", err)
            }
    }

    //如果header头 grpc-status-details-bin 为nil, 则构建code + message 错误
  if statusGen == nil {
        statusGen = status.New(rawStatusCode, grpcMessage)
    }

    // if client received END_STREAM from server while stream was still active, send RST_STREAM
    rst := s.getState() == streamActive
    t.closeStream(s, io.EOF, rst, http2.ErrCodeNo, statusGen, mdata, true)

核心点出现在 grpc-status-details-bin 头上, 如果不为空, 则可调用下面方法直接转换为 Status 的结构.

func decodeGRPCStatusDetails(rawDetails string) (*status.Status, error) {
    //base64 decode 解码
  v, err := decodeBinHeader(rawDetails)
    if err != nil {
        return nil, err
    }
  //对 Status 进行 Unmarshal
    st := &spb.Status{}
    if err = proto.Unmarshal(v, st); err != nil {
        return nil, err
    }
    return status.FromProto(st), nil
}

下面是抓包的图, 这样看起来更直观

grpc接口抛出异常 grpc error 错误处理_自定义

  1. grace-status 就是我们说的code码
  2. grpc-message 是我们的错误消息
  3. grpc-status-details-bin 包含我们错误定义的所有信息, value 是base64编码后的结果, 解码后得到
     $query db err, id=1: record not found;(type.googleapis.com/google.rpc.ErrorInfo SERVICE_ERROR
    最开始的我们提到了 Status结构 中有个  Details []*any.Any  类型, 我们可以灵活的挂载一些其他自定义信息, 现在根据 type 可以看到挂在了  type.googleapis.com/google.rpc.ErrorInfo 结构
    我们看下 google.rpc.ErrorInfo 这个结构样子

grpc接口抛出异常 grpc error 错误处理_Code_02

我们看到这个结构里有map类型的Metadata, 可以很方便的携带我们自定义的数据, 下文将会使用到这个map

grpc返回错误时, 使用了grpc内置标准错误, 如下结构

//status.Error(codes.Canceled, "query db err, id=1: record not found"), 
type Error struct {
    s *Status
}

如果调用方想要获取到对应的Code码和 Message 内容, 还需要获取到Status的值, 通过 FromError 即可

grpc@v1.45.0/status/status.go:91

type Error struct {
    s *Status
}

func FromError(err error) (s *Status, ok bool) {
    if err == nil {
        return nil, true
    }
    if se, ok := err.(interface {
        GRPCStatus() *Status
    }); ok {
        return se.GRPCStatus(), true
    }
    return New(codes.Unknown, err.Error()), false
}


// GRPCStatus returns the Status represented by se.
type Error struct {
    s *Status
}
func (e *Error) GRPCStatus() *Status {
    return e.s
}

在Error中, 必须实现 GRPCStatus() 接口, 这里的 Error 是grpc官方默认的实现, 只是返回了 e.s, s就是对应的 Status 结构.

概括:  传输 error 使用了 Status 结构, 对应服务端进行编码, 通过header头传递.

客户端根据header头进行解码, 创建 Status 结构对象。

2.grpc code码

在返回错误 Status 结构中, 有一个字段为 code 码,  grpc 预定义了一些常量, 不建议我们自行对它进行设置.

通常 code码对应于 http状态码, grpc header 头为上面提到的 grpc-status

在其中有这样一段话, 描述了关于定义错误码的一些想法, 仅供参考.

Google API 必须使用 google.rpc.Code 定义的规范错误代码。单个 API 避免定义其他错误代码,因为开发人员不太可能编写用于处理大量错误代码的逻辑。作为参考,每个 API 调用平均处理 3 个错误代码意味着大多数应用的逻辑只是用于错误处理,这对开发人员而言并非好体验。

http状态码与grpc code码映射关系

grpc接口抛出异常 grpc error 错误处理_grpc接口抛出异常_03

相信读者已经了解了设计code码的初衷。

回到开头的问题, 如果我的项目同样提供 http 服务, 如果基于grpc code映射的http状态码做业务上面判断, 是不符合我们现有报警、重试等底层框架设计的.

我们不讨论哪种方法优劣

我们现有业务判断是通过body体的一个类似stat的字段, 而不是http状态码,  我们认为 http状态码反映的是整个服务的状态情况而不是业务上的某个状态, 所以我们认为这个设计是不符合我们需求的.

资料参考:

source code: grpc@v1.45.0/codes/codes.go:31

code码含义:  https://grpc.github.io/grpc/core/md_doc_statuscodes.html

google error 错误规范: https://cloud.google.com/apis/design/errors

3.怎么设计我们的http响应

我们先看下 kratos 底层是如何包装grpc Status错误的.

服务端发送错误
//传递自定义的错误
type CustomError struct {
    state         protoimpl.MessageState
    sizeCache     protoimpl.SizeCache
    unknownFields protoimpl.UnknownFields

    Code     int32             `protobuf:"varint,1,opt,name=code,proto3" json:"code,omitempty"`
    Reason   string            `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"`
    Message  string            `protobuf:"bytes,3,opt,name=message,proto3" json:"message,omitempty"`
    Metadata map[string]string `protobuf:"bytes,4,rep,name=metadata,proto3" json:"metadata,omitempty"       protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
}

// WithMetadata with an MD formed by the mapping of key, value.
func (e *CustomError) WithMetadata(md map[string]string) *Error {
    err := proto.Clone(e).(*Error)
    err.Metadata = md
    return err
}

// New returns an error object for the code, message.
func New(code int, reason, message string) *Error {
    return &CustomError{
        Code:    int32(code),
        Message: message,
        Reason:  reason,
    }
}

//实现 GRPCStatus() 接口, 用于 FromError 获取 status 底层结构使用
//httpstatus.ToGRPCCode 将http状态码转换为grpc-code进行传递
//WithDetails增加 Details 拓展信息(我们后续将拓展的 stat 字段加入了 MetaData 数据中)
//最终转换成grpc标准的 Status 结构返回
func (e *CustomError) GRPCStatus() *status.Status {
   s, _ := status.New(httpstatus.ToGRPCCode(int(e.Code)), e.Message).
      WithDetails(&errdetails.ErrorInfo{
         Reason:   e.Reason,
         Metadata: e.Metadata,
      })
   return s
}

上面自定义的 error 实现了 GRPCStatus 接口用于转换标准的 grpc error

我们看下grpc 底层如何使用的

func FromError(err error) (s *Status, ok bool) {

if err == nil {
        return nil, true
    }
    if se, ok := err.(interface {
        GRPCStatus() *Status
    }); ok {
        return se.GRPCStatus(), true
    }
    return New(codes.Unknown, err.Error()), false
}
客户端解析错误:

客户端解析错误就比较简单了, grpc 根据header转换为Status结构, Status 结构的信息转为我们自定义的错误.

func FromError(err error) *Error {
    if err == nil {
        return nil
    }
    if se := new(Error); errors.As(err, &se) {
        return se
    }
    //调用FromError方法, 获取Grpc Status 结构
    gs, ok := status.FromError(err)
    if ok {
    //对grpc code码进行转换为http状态码
        ret := New(
            httpstatus.FromGRPCCode(gs.Code()),
            UnknownReason,
            gs.Message(),
        )

    //解析附加Details信息
        for _, detail := range gs.Details() {
            switch d := detail.(type) {
            case *errdetails.ErrorInfo:
                ret.Reason = d.Reason
                return ret.WithMetadata(d.Metadata)
            }
        }
        return ret
    }
  //默认错误
    return New(UnknownCode, UnknownReason, err.Error())
}

grpc 服务端响应时, 使用 GRPCStatus() 下 ToGRPCCode 方法响应将http code码转换为对应的grpc code码

而在接收时, 则需要使用 FromError() 下 FromGRPCCode 方法对grpc code 码转换为对应的 http code 码

回到开头提到的第三个问题上, 为了符合现有规范, 我们将按以下模式返回:

新增 stat 字段, 用于下发服务端相应状态.

新增 msg 字段, 用于友好的错误展示

1. 【客户端/服务端】实现错误定义pb
enum ErrorReason {
    option (errors.default_code) = 500;
    option (errors.default_stat) = 1;
    option (errors.default_msg) = "服务错误";
    //状态码=200 stat=2 msg="活动找不到错误"
    SERVICE_ERROR = 0 [(errors.code) = 200, (errors.stat) = 2,(errors.msg) = "活动找不到错误"];
    //状态码=404 stat=1 msg="服务错误"
    RULE_NOT_FOUND = 1 [(errors.code) = 404];
}
2. 【客户端/服务端/插件】errors.proto 定义新增字段
extend google.protobuf.EnumOptions {
  int32 default_code = 1108;
  int32 default_stat = 1109;
  string default_msg = 1110;
}

extend google.protobuf.EnumValueOptions {
  int32 code = 1111;
  int32 stat = 1112;
  string msg = 1113;
}
3.protoc-gen-go-errors 插件

最终生成

func ErrorServiceError(format string, args ...interface{}) *errors.Error {
  //将stat状态码携带进 Metadata 中, msg自定义
    md := map[string]string{
        "stat": "2",
    }
    return errors.New(200, ErrorReason_SERVICE_ERROR.String(), fmt.Sprintf(format, args...)).WithMetadata(md)
}

func ErrorMsgServiceError() *errors.Error {
  //将stat状态码携带进 Metadata 中, 使用标准msg
    md := map[string]string{
        "stat": "2",
    }

    return errors.New(200, ErrorReason_SERVICE_ERROR.String(), "活动找不到错误").WithMetadata(md)

}


func IsServiceError(err error) bool {
    if err == nil {
        return false
    }
    e := errors.FromError(err)
    stat := e.Metadata["stat"]
  //stat 或 reason 判断满足其一 && 状态码符合
    return (e.Reason == ErrorReason_SERVICE_ERROR.String() || stat == "2") && e.Code == 200
}

protoc 插件开发参考: https://taoshu.in/go/create-protoc-plugin.html

插件的目标是方便错误返回与判断.

在返回错误时, 自动将对应的 stat 和 msg 装填到 metadata

而在错误判断时, 提供了 Reason 和 stat 两种判断方式 (grpc 不需要关注 stat)

关于返回值部分

首先确定返回的基本模版, 与我们现有业务一致即可

response 结构定义

type HttpStandardResponse struct {
    Stat int32       `json:"stat"` //业务状态 1成功 0失败
    Code int32       `json:"code"` 
    Msg  string      `json:"msg"`
    Data interface{} `json:"data"`
}

服务端对消息体进行 HttpStandardResponse 结构编码

//错误编码
//errors metadata中获取stat, 作为顶级字段响应返回
func ErrorEncoderHandler(w netHttp.ResponseWriter, r *netHttp.Request, err error) {
    se := errors.FromError(err)
    statCode, _ := strconv.Atoi(se.Metadata["stat"])
    response := &codec.HttpStandardResponse{
        Stat: int32(statCode),
        Code: se.Code,
        Msg:  se.Message,
        Data: struct{}{},
    }

    codeObj, _ := http.CodecForRequest(r, "Accept")
    body, err := codeObj.Marshal(response)
    if err != nil {
        w.WriteHeader(netHttp.StatusInternalServerError)
        return
    }
    w.Header().Set("Content-Type", "application/"+codeObj.Name())
    w.WriteHeader(int(se.Code))
    _, _ = w.Write(body)
}

//响应解码
func ResponseEncoderHandler(w netHttp.ResponseWriter, r *netHttp.Request, v interface{}) error {
    response := &codec.HttpStandardResponse{
        Stat: codec.STAT_SUCCESS,
        Code: netHttp.StatusOK,
        Msg:  codec.STAT_SUCCESS_MSG,
        Data: v,
    }

    codeObj, _ := http.CodecForRequest(r, "Accept")
    data, err := codeObj.Marshal(response)
    if err != nil {
        return err
    }
    w.Header().Set("Content-Type", "application/"+codeObj.Name())
    _, err = w.Write(data)
    if err != nil {
        return err
    }
    return nil
}

客户端对 HttpStandardResponse 结构响应值进行解码

客户端对 body 体进行转换, 如果 stat!=1 则将 body 体的 stat message 转换为errors错误.

body体中的 data 转为 pb 结构

//调用包含data结构, 使用此方法, 解析层级为data下数据
//如果服务端使用encoder, 则会再pb定义上装填到data字段, 原则上pb response不定义data字段
//如果pb文件必须保留data字段, 则需额外指定 response_body: "data"
//
//    option (google.api.http) = {
//        get: "/Course/Courseinfo/getxx/xx",
//        response_body: "data",
//    };

func ResponseDecoderHandler(ctx context.Context, res *netHttp.Response, v interface{}) error {
    defer res.Body.Close()
    data, err := io.ReadAll(res.Body)
    if err != nil {
        return err
    }

    response := &codec.HttpStandardResponse{}
    if err = http.CodecForResponse(res).Unmarshal(data, response); err == nil {
        if err = errorDecoder(ctx, res, response); err != nil {
            return err
        }
    } else {
        return errors.Errorf(res.StatusCode, errors.UnknownReason, err.Error())
    }

    //解出最外层data, 然后转换为pb格式文件
    byteData, _ := json.Marshal(response.Data)
    return http.CodecForResponse(res).Unmarshal(byteData, v)
}

// ErrorDecoder is an HTTP error decoder.
func errorDecoder(ctx context.Context, res *netHttp.Response, httpStandardResponse *codec.HttpStandardResponse) error {
    if httpStandardResponse.Stat == codec.STAT_SUCCESS {
        return nil
    }

    md := map[string]string{
        "stat": strconv.Itoa(int(httpStandardResponse.Stat)),
    }
    return errors.New(res.StatusCode, "", httpStandardResponse.Msg).WithMetadata(md)
}

//兼容error状态码异常情况下获取
func ErrorDecoderHandler(ctx context.Context, res *netHttp.Response) error {
    if res.StatusCode >= 200 && res.StatusCode <= 299 {
        return nil
    }
    defer res.Body.Close()
    data, err := io.ReadAll(res.Body)
    if err == nil {
        httpStandardResponse := &codec.HttpStandardResponse{}
        if err = http.CodecForResponse(res).Unmarshal(data, httpStandardResponse); err != nil {
            return errors.Errorf(res.StatusCode, errors.UnknownReason, err.Error())
        }

        md := map[string]string{
            "stat": strconv.Itoa(int(httpStandardResponse.Stat)),
        }
        return errors.New(res.StatusCode, "", httpStandardResponse.Msg).WithMetadata(md)
    }
    return errors.Errorf(res.StatusCode, errors.UnknownReason, err.Error())
}
  1. 最终交互图:
     
  2. grpc接口抛出异常 grpc error 错误处理_自定义_04

可能的疑问:

  1. 是否可以模仿 grpc 通过header头传递方式错误消息?

在某种程度是可以的, 但是我们面临兼容现有项目和不同接入端, 解析也要比json繁琐.

  1. 有些场景无法 unwarp data 怎么办?例如:message GetXXReply {   map<string, XXInfo> data = 3;}类似这种data结构不是固定的 key value 形式, 无法直接将data这一层级忽略掉, 经过 encoder 后回包含两层 data, 类似于
{
   "data":{
       "data":{
           "2":{
               "key1":"val1",
               "key2":"val2"
           }
       }
   }
}

  1. 通过上面的代码我们可以解出第一层, 第二层 data 可以在指定 option.http 时候指定 response_body: "data", 在解析时将会指定字段解析, 类似: photo.Unmarshal(byteBody, reply.data)