179 lines
6.0 KiB
Markdown
179 lines
6.0 KiB
Markdown
|
|
---
|
||
|
|
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-分布式追踪]] — 错误链路在追踪系统中的标注方式
|