--- tags: [grpc, error-handling, status-codes, graceful-degradation] create time: 2026-05-07 16:00 --- # 错误处理与状态码规范 ## 概述 良好的错误处理决定了微服务系统的**可观测性**和**恢复速度**。gRPC 设计了 16 个标准状态码,每个都有明确的语义边界。正确使用它们,可以让调用方精准判断该重试、该降级、还是直接报错。 > [!question] 先看一个反面教材 > > 以下两个服务的错误处理方式,哪个更好? > > **服务 A**:所有异常一律返回 `InternalError: something went wrong` > > **服务 B**:参数校验失败返回 `InvalidArgument`,资源不存在返回 `NotFound`,数据库超时返回 `Unavailable` 答案是显而易见的。但现实工程中,服务 A 的比例远高于服务 B——原因是很多开发者不了解状态码的准确含义,或者嫌麻烦直接 return nil, fmt.Errorf(...)。 ## gRPC 状态码一览 gRPC 定义了 **16 个标准状态码**,与 HTTP 状态码不完全映射,有自己的语义体系: ```mermaid graph TB OK["OK - 成功"] subgraph "客户端错误 4xx 对应" CANCELLED["CANCELLED - 客户端取消"] UNKNOWN["UNKNOWN - 未知错误"] INVALID_ARG["INVALID_ARGUMENT - 参数无效"] DEADLINE_EX["DEADLINE_EXCEEDED - 超时"] NOT_FOUND["NOT_FOUND - 资源不存在"] ALREADY_EXIST["ALREADY_EXISTS - 重复创建"] PERMISSION_DENIED["PERMISSION_DENIED - 权限不足"] UNAUTH["UNAUTHENTICATED - 未认证"] end subgraph "服务端错误 5xx 对应" RESOURCE_EXP["RESOURCE_EXHAUSTED - 资源耗尽"] UNAVAIL["UNAVAILABLE - 服务不可用"] DATA_LOSS["DATA_LOSS - 数据损坏"] end subgraph "未实现" UNIMP["UNIMPLEMENTED - 方法未实现"] INTERNAL_ERR["INTERNAL - 内部错误"] UNIMP ~~~ UNAVAIL INTERNAL_ERR ~~~ DATA_LOSS UNIMP --- UNAVAIL end ``` ### 状态码速查表 | 业务含义 | 推荐状态码 | HTTP 等价 | |---------|-----------|----------| | 参数校验失败 | `InvalidArgument` | 400 | | 资源不存在 | `NotFound` | 404 | | 重复创建 | `AlreadyExists` | 409 | | 权限不足 | `PermissionDenied` | 403 | | 未认证 | `Unauthenticated` | 401 | | 超时 | `DeadlineExceeded` | 504 | | 服务挂了/连接断开 | `Unavailable` | 503 | | 限流 | `ResourceExhausted` | 429 | | 方法未定义 | `Unimplemented` | 501 | | 代码 bug | `Internal` | 500 | ## 正确的错误处理方式 ```go // ❌ 错误示范:用普通 error 包装,丢失 gRPC 语义 return nil, fmt.Errorf("failed to get order: %w", db.ErrNotFound) // ✅ 正确示范:使用 status.Error 保持 gRPC 协议一致性 if errors.Is(err, db.ErrNotFound) { return nil, status.Errorf(codes.NotFound, "order %s not found", id) } // ✅ 更精细:带上详情(status details)——客户端可以程序化解析 detail := &errdetails.BadRequest{ FieldViolations: []*errdetails.BadRequest_FieldViolation{{ Field: "user_id", Description: "must be a valid UUID", }}, } return nil, status.New(codes.InvalidArgument, "validation failed").WithDetails(detail).Err() ``` ### 为什么要用 status.Error | 维度 | fmt.Errorf | status.Error | |------|-----------|-------------| | 客户端获取状态码 | 需要 parse 字符串 | `status.Code(err)` 直接获取 | | 跨语言一致 | 各语言行为不一致 | gRPC 协议标准化 | | 携带结构化错误 | 不支持 | 通过 `WithDetails` 携带 proto detail | | 配合拦截器重试 | 无法识别 | 拦截器根据 codes.* 决定重试 | ## 状态码详情 (Status Details) gRPC 允许在错误响应中附加结构化详情,这对自动化运维尤其重要: ```go // 超时场景中附带重试信息 retryInfo := &errdetails.RetryInfo{ RetryDelay: durationpb.New(2 * time.Second), } // 权限场景中附带帮助链接 help := &errdetails.Help{ Links: []*errdetails.Help_Link{{ Url: "https://internal.wiki/perm-error", Description: "如何申请访问权限", }}, } status.New(codes.PermissionDenied, "no access"). WithDetails(retryInfo, help). Err() ``` 客户端可以程序化提取这些信息: ```go st := status.Convert(err) for _, d := range st.Details() { switch detail := d.(type) { case *errdetails.RetryInfo: time.Sleep(detail.RetryDelay.AsDuration()) // 执行重试 } } ``` ## 客户端优雅降级 ```go resp, err := client.CreateOrder(ctx, req) switch status.Code(err) { case codes.NotFound: // 降级:尝试加载缓存数据 return fallbackFromCache(ctx, id) case codes.DeadlineExceeded, codes.Unavailable: // 降级:返回友好提示或部分数据 return partialOrder(id), nil default: return nil, err } ``` > [!keypoint] 黄金法则 > > - 永远用 `codes.*` 而不用 `fmt.Errorf` 做 gRPC 返回值 > - 不要吞掉所有错误一律返回 `Internal` —— 这会让排查问题无从下手 > - 客户端根据状态码决定重试还是降级,而不是所有错都 retry ## 何时返回 Internal `Internal` 是最不应该被使用的状态码。只在以下情况使用: | 场景 | 示例 | |------|------| | 代码逻辑 bug | panic recover、nil pointer dereference | | 不可恢复的内部状态 | 数据库连接池耗尽、配置文件解析失败 | | 子服务报错但不确定原因 | 下游返回的 code 不在预期范围内 | > [!warning] Internal 不等于"随便的错" > > 如果你的日志里有大量的 `Internal` 错误,这通常意味着上游的错误分类不够细,掩盖了真正的根因。每次遇到不知道该怎么映射的错误时,优先考虑是否能归入现有的某个 code。 ## 关联笔记 - [[04-拦截器]] — 拦截器根据错误码决定是否触发自动重试 - [[07-最佳实践]] — 重试策略中与错误状态的配合配置 - [[02-服务治理/06-容错模式]] — 熔断器在收到特定错误码后触发熔断 - [[02-服务治理/03-分布式追踪]] — 错误链路在追踪系统中的标注方式