错误处理:状态码、错误详情与对外映射
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
}
三条纪律:
return err会把错误归到Unknown:gRPC 只认实现了GRPCStatus() *Status的错误,普通error一律转成Unknown(例外是context.DeadlineExceeded/Canceled,它们会被正确识别)。- 内部细节不要进 message:文案会写进
grpc-messagetrailer,客户端和日志都能看到。SQL 语句、内网 IP、堆栈留给服务端日志,对外只给稳定文案。 - 不要返回
(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 状态码 | HTTP | gRPC 状态码 | HTTP |
|---|---|---|---|
OK | 200 | Unimplemented | 501 |
InvalidArgument、FailedPrecondition、OutOfRange | 400 | Internal、Unknown、DataLoss | 500 |
Unauthenticated | 401 | Unavailable | 503 |
PermissionDenied | 403 | DeadlineExceeded | 504 |
NotFound | 404 | Canceled | 499 |
AlreadyExists、Aborted | 409 | ResourceExhausted | 429 |
这张表要写成网关里唯一的一个映射函数,禁止各 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 状态码只在网关出口按一张表翻译。