错误处理:状态码、错误详情与对外映射

RPC 跨进程之后,error 只剩一个字符串,调用方没法据此判断该重试、该改参数还是该告警。gRPC 的做法是:错误类别放进状态码(status code),机器可读的细节放进错误详情(error details),两者随 trailer 一起回给客户端。IM 里最典型的分歧是「会话不存在」和「下游超时」——前者不该重试,后者要退避重试,全靠状态码区分。

状态码:先选对语义,再谈文案

服务端用 status.Error(codes.X, "文案") 构造错误。选码时先问「这是谁的错、调用方该做什么」:

状态码什么时候用它(IM 示例)
InvalidArgument参数非法:text 为空、seq 为负、uid 缺失
Unauthenticated / PermissionDenied令牌缺失或过期 / 令牌有效但无权访问该会话
NotFound / FailedPrecondition会话、消息不存在 / 前置状态不满足(未登录就发消息)
AlreadyExists / Aborted幂等键重复、重复建群 / 并发冲突,版本号不匹配
ResourceExhausted触发限流、消息长度超限、配额用尽
Unavailable下游不可用、连接断开(可退避重试,第 10 章)
DeadlineExceeded / Canceled超时 / 调用方主动取消(第 7 章)
Unimplemented方法不存在、新旧版本不匹配
Internal / Unknown服务端 bug、panic 兜底 / 没按规范包装的普通 error

Internal 与 Unknown 的区别是「谁的问题」:Internal 是服务端明确承认自己错了,Unknown 多半意味着某处 return err 漏了包装。两者在监控上要分开告警,因为排查方向完全不同。

服务端:不要 return 裸 error

func (s *messageServer) SendMsg(ctx context.Context, req *pb.SendMsgReq) (*pb.SendMsgResp, error) {
	if req.GetText() == "" {
		return nil, status.Error(codes.InvalidArgument, "text 不能为空")
	}
	if len(req.GetText()) > 4096 {
		return nil, status.Errorf(codes.InvalidArgument, "text 过长:%d 字节", len(req.GetText()))
	}
	conv, err := s.store.GetConv(ctx, req.GetConvId())
	if errors.Is(err, sql.ErrNoRows) {
		return nil, status.Error(codes.NotFound, "会话不存在")
	}
	if err != nil {
		log.Printf("查会话失败: %v", err) // 内部细节只进日志
		return nil, status.Error(codes.Internal, "服务内部错误")
	}
	_ = conv
	return &pb.SendMsgResp{MsgId: "m-1", Seq: 1}, nil
}

三条纪律:

  1. return err 会把错误归到 Unknown:gRPC 只认实现了 GRPCStatus() *Status 的错误,普通 error 一律转成 Unknown(例外是 context.DeadlineExceeded/Canceled,它们会被正确识别)。
  2. 内部细节不要进 message:文案会写进 grpc-message trailer,客户端和日志都能看到。SQL 语句、内网 IP、堆栈留给服务端日志,对外只给稳定文案。
  3. 不要返回 (nil, nil):一元处理器返回未包装的 nil 响应时,客户端收到的是一个字段全默认值的空响应而不是错误,会被静默当成成功——比报错更难查。

错误详情:给程序看的结构化信息

文案给人看,errdetails 给程序看。服务端用 Status.WithDetails 挂载,客户端用 st.Details() 取回:

st := status.New(codes.InvalidArgument, "参数校验失败")
st, err := st.WithDetails(&errdetails.BadRequest{
	FieldViolations: []*errdetails.BadRequest_FieldViolation{
		{Field: "text", Description: "不能为空"},
	},
})
if err != nil {
	return nil, status.Error(codes.Internal, "构造错误详情失败")
}
return nil, st.Err()

客户端解析时先 status.FromError,再按类型断言遍历,别用字符串匹配文案:

st, ok := status.FromError(err)
if !ok {
	return err // 本地错误或第三方库返回的普通 error
}
switch st.Code() {
case codes.InvalidArgument:
	for _, d := range st.Details() {
		if br, ok := d.(*errdetails.BadRequest); ok {
			for _, v := range br.GetFieldViolations() {
				log.Printf("字段 %s 非法:%s", v.GetField(), v.GetDescription())
			}
		}
	}
case codes.NotFound:
	log.Println("会话不存在,刷新会话列表")
case codes.Unavailable, codes.ResourceExhausted:
	log.Println("可退避重试(第 10 章)")
}

细节会编码进 grpc-status-details-bin,所以只放必要的字段。业务侧的细分错误码(如 10023 = 消息已被撤回)建议用 errdetails.ErrorInfo 的 Reason 与 Metadata 承载,不要塞进状态码。顺带记一句:status.Code(nil) == codes.OK,日志里可以直接写 status.Code(err)。

对外映射:只在网关出口翻译

内部一律用 gRPC 状态码,HTTP 状态码只在网关出口映射(第 13 章):

gRPC 状态码HTTPgRPC 状态码HTTP
OK200Unimplemented501
InvalidArgument、FailedPrecondition、OutOfRange400Internal、Unknown、DataLoss500
Unauthenticated401Unavailable503
PermissionDenied403DeadlineExceeded504
NotFound404Canceled499
AlreadyExists、Aborted409ResourceExhausted429

这张表要写成网关里唯一的一个映射函数,禁止各 handler 自己 switch,否则同一个 NotFound 在不同接口上会返回不同状态码。

常见坑

  • status.FromError 的第二个返回值别丢:ok == false 说明这不是 gRPC 错误,此时 st.Code() 恒为 Unknown,拿它做重试判断会把本地错误也重试一遍。
  • 包装错误用 %w 而不是 %v:fmt.Errorf("查会话: %w", err) 能保留状态码(%v 会变成 Unknown);注意 %w 之后 status 的 message 会变成包装后的整段文案。
  • codes.Unknown 不是兜底码:明确知道自己错了就用 Internal,否则监控无法区分「服务端 bug」与「上游没规范返回」。
  • 错误文案保持短且无换行:它走 HTTP/2 header/trailer,有长度上限,超长会被截断或直接报错。
  • 拦截器里转码要幂等:先 if _, ok := status.FromError(err); ok { return err },再包装,避免把已有状态码盖成 Internal(第 08 章)。
  • 流式 RPC 中途也能出错:服务端在 Send 之间直接 return status.Error(...),客户端会在 Recv 时收到该状态码,不必等整条流结束。

小结:服务端用 status.Error(codes.X, 文案) 返回错误、用 WithDetails 挂结构化详情,客户端用 status.FromError/status.Code 分流;Internal 对应服务端 bug、Unknown 多半对应漏包装的裸 error;HTTP 状态码只在网关出口按一张表翻译。

笔记加载中…