☰
gRPC 状态码全是 Unknown:错误透传与 status 包装的正确写法
2026/10/16 6:33:08 网站建设 项目流程

我不会起名字322· 后端 / 算法 / 数据库

📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL

文章目录

  • gRPC 状态码全是 Unknown:错误透传与 status 包装的正确写法
    • 一、为什么全是 Unknown
    • 二、正确写法:用 `status.Error`
    • 三、客户端怎么正确判断错误
    • 四、跨服务调用时的错误翻转
      • 错误做法:直接往上抛
      • 正确做法:重新包装
    • 五、携带额外错误信息:status details
    • 六、哪些错误"不该"是 Unknown
    • 七、一个常见的整体错误:panic 默认变成 Unknown
    • 八、带走物

gRPC 状态码全是 Unknown:错误透传与 status 包装的正确写法

一、为什么全是 Unknown

gRPC 的错误是状态码 + 消息的组合。但很多人遇到的情况是:不管服务端返回什么错误,客户端收到的都是Unknown。

rpc error: code = Unknown desc = 数据库连接失败

"数据库连接失败"这个消息传过来了,但状态码是Unknown而不是Internal。

原因是:gRPC 只会把*status.Status类型的错误当作状态码传递,其他任何 error 都会被包装成codes.Unknown。

// ❌ 这样返回,客户端收到的是 Unknownreturnnil,errors.New("数据库连接失败")// ❌ 这样也是 Unknown(fmt.Errorf 包装的普通 error)returnnil,fmt.Errorf("查询失败: %w",err)

gRPC 的设计是:你返回的 error 必须能转换成 status。转换函数(status.FromError)的规则是:

funcFromError(errerror)(s*Status,okbool){iferr==nil{returnnil,true}// 只有实现了 GRPCStatus() 接口的才 ok=trueifse,ok:=err.(interface{GRPCStatus()*Status});ok{returnse.GRPCStatus(),true}// 其他一律 Unknown,消息保留原 error 的字符串returnNew(codes.Unknown,err.Error()),false}

所以你看到的 “desc = 数据库连接失败” 其实是原始 error 的字符串,被塞进了 Unknown 的 message 里。

二、正确写法:用status.Error

import"google.golang.org/grpc/status"import"google.golang.org/grpc/codes"// ✅ 正确:显式指定状态码returnnil,status.Error(codes.Internal,"数据库连接失败")// ✅ 带格式化returnnil,status.Errorf(codes.NotFound,"订单 %d 不存在",id)// ✅ 参数错误ifreq.Id==""{returnnil,status.Error(codes.InvalidArgument,"id 不能为空")}

包一层业务错误转换函数是个好习惯:

// 把领域错误映射成 gRPC 状态码functoGRPCError(errerror)error{iferr==nil{returnnil}switch{caseerrors.Is(err,domain.ErrNotFound):returnstatus.Error(codes.NotFound,err.Error())caseerrors.Is(err,domain.ErrInvalidParam):returnstatus.Error(codes.InvalidArgument,err.Error())caseerrors.Is(err,domain.ErrPermissionDenied):returnstatus.Error(codes.PermissionDenied,err.Error())caseerrors.Is(err,context.DeadlineExceeded):returnstatus.Error(codes.DeadlineExceeded,err.Error())default:returnstatus.Error(codes.Internal,err.Error())}}// handler 里统一用func(s*Server)GetOrder(ctx context.Context,req*pb.GetReq)(*pb.Order,error){o,err:=s.svc.Get(ctx,req.Id)iferr!=nil{returnnil,toGRPCError(err)// 一行搞定}returntoProto(o),nil}

这样客户端就能按状态码做区分处理了。

三、客户端怎么正确判断错误

绝对不要用字符串匹配:

// ❌ 脆弱:服务端改一下文案就失效ifstrings.Contains(err.Error(),"不存在"){// ...}

用status.Code():

import"google.golang.org/grpc/status"resp,err:=client.GetOrder(ctx,req)iferr!=nil{switchstatus.Code(err){casecodes.NotFound:returnnil,ErrOrderNotFound// 转成自己的领域错误casecodes.InvalidArgument:returnnil,ErrBadParamcasecodes.DeadlineExceeded:returnnil,ErrTimeoutcasecodes.Unavailable:returnnil,ErrServiceDown// 可重试casecodes.Internal:returnnil,ErrServerErrordefault:returnnil,err}}

取原始消息:

st,ok:=status.FromError(err)ifok{log.Printf("code=%s msg=%s",st.Code(),st.Message())}

四、跨服务调用时的错误翻转

这是最容易出错的地方:服务 A 调服务 B,B 返回NotFound,A 要怎么返回给它的调用方?

错误做法:直接往上抛

// ❌ 直接把下游的错误透传出去,语义可能不对func(s*AServer)GetDetail(ctx context.Context,req*pb.Req)(*pb.Detail,error){order,err:=s.orderClient.GetOrder(ctx,&pb.GetOrderReq{Id:req.OrderId})iferr!=nil{returnnil,err// 下游的 NotFound 直接变成 A 的 NotFound}...}

问题:B 的NotFound(订单不存在)和 A 的NotFound(详情不存在)是两回事。而且如果你直接透传,调用方无法区分"订单不存在"和"详情不存在"。

正确做法:重新包装

func(s*AServer)GetDetail(ctx context.Context,req*pb.Req)(*pb.Detail,error){order,err:=s.orderClient.GetOrder(ctx,&pb.GetOrderReq{Id:req.OrderId})iferr!=nil{// 保留下游状态码用于日志/排查,但对外重新定义语义log.Printf("调用 order 服务失败 code=%s: %v",status.Code(err),err)switchstatus.Code(err){casecodes.NotFound:// 订单不存在 → 对 A 的调用方来说,这是"详情不存在"returnnil,status.Errorf(codes.NotFound,"详情不存在: 订单 %s 未找到",req.OrderId)casecodes.Unavailable:// 下游不可用 → 对 A 的调用方来说是"服务暂时不可用"(可重试)returnnil,status.Error(codes.Unavailable,"依赖服务暂时不可用")casecodes.DeadlineExceeded:returnnil,status.Error(codes.DeadlineExceeded,"下游响应超时")default:returnnil,status.Error(codes.Internal,"内部错误")}}...}

核心原则:每一层都要把下游的错误"翻译"成本层的语义,而不是原样透传。同时把下游的原始错误记进日志,方便排查。

五、携带额外错误信息:status details

status.Error只能带一个字符串。如果需要结构化信息(比如字段级校验错误),用details:

import"google.golang.org/grpc/status"import"google.golang.org/protobuf/types/known/anypb"import"google.golang.org/genproto/googleapis/rpc/errdetails"funcbadRequest(field,descstring)error{st:=status.New(codes.InvalidArgument,"参数校验失败")// 带上结构化的字段错误br:=&errdetails.BadRequest{FieldViolations:[]*errdetails.BadRequest_FieldViolation{{Field:field,Description:desc},},}st,_=st.WithDetails(br)returnst.Err()}

客户端解析:

st,_:=status.FromError(err)for_,d:=rangest.Details(){ifbr,ok:=d.(*errdetails.BadRequest);ok{for_,v:=rangebr.FieldViolations{log.Printf("字段 %s: %s",v.Field,v.Description)}}}

WithDetails会返回新的 status,要重新赋值(上面那行st, _ = st.WithDetails(br))。而且如果 details 的类型客户端没有注册,WithDetails会返回错误。

六、哪些错误"不该"是 Unknown

对照表,选状态码时参考:

场景状态码可重试
参数缺失/格式错InvalidArgument❌
资源不存在NotFound❌
已存在(重复创建)AlreadyExists❌
无权限PermissionDenied❌
未认证Unauthenticated❌(先刷新 token)
限流ResourceExhausted✅ 带退避
服务暂时不可用Unavailable✅
超时DeadlineExceeded⚠️ 谨慎
下游挂了但本服务正常Internal或Unavailable视情况
未捕获的 panicInternal❌

"该重试的"和"不该重试的"必须分开——这是重试策略能正确工作的前提(详见重试风暴那篇)。

七、一个常见的整体错误:panic 默认变成 Unknown

如果 handler 里 panic 了(而且没有 recovery 拦截器),gRPC 会恢复并返回Unknown,原始 panic 信息丢失:

rpc error: code = Unknown desc = panic: runtime error: index out of range

所以必须加 recovery 拦截器,并且显式转成Internal:

funcrecoveryInterceptor(ctx context.Context,reqinterface{},info*grpc.UnaryServerInfo,handler grpc.UnaryHandler)(respinterface{},errerror){deferfunc(){ifr:=recover();r!=nil{buf:=make([]byte,4096)n:=runtime.Stack(buf,false)log.Printf("PANIC %s: %v\n%s",info.FullMethod,r,buf[:n])err=status.Errorf(codes.Internal,"内部错误: %v",r)}}()returnhandler(ctx,req)}

八、带走物

服务端三条规则:

1. 永远用 status.Error / status.Errorf 返回错误,不要裸 errors.New 2. 建一个 toGRPCError 把领域错误映射成状态码 3. 加 recovery 拦截器,把 panic 转成 Internal(默认是 Unknown 且丢信息)

客户端两条规则:

1. 用 status.Code(err) 判断,绝不字符串匹配 2. 跨层调用时把下游错误"翻译"成本层语义,同时把原始错误记进日志

一句话结论:Unknown的本质是"服务端返回了一个 gRPC 不认识的 error"——只要用status.Error显式指定状态码,它就会消失;而状态码的准确与否,直接决定了调用方的重试和容错逻辑能不能正确工作。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询