Appearance
错误处理与状态码
gRPC 的错误处理远不止「返回一个 error」。它有一套跨语言的标准状态码体系,支持携带结构化的错误详情(rich error),能在 HTTP/2 层面传播到任何语言的客户端。本篇深入 gRPC 的状态码语义、status 包的用法、errdetails 丰富错误模式、HTTP 与 gRPC 的状态码映射,以及错误日志与追踪的最佳实践。
一、gRPC 状态码体系
gRPC 定义了 17 个标准状态码,所有语言实现一致。以下是完整列表及其语义:
| 状态码 | 值 | 含义 | HTTP 映射 |
|---|---|---|---|
OK | 0 | 成功 | 200 |
Canceled | 1 | 客户端取消 | 499 |
Unknown | 2 | 服务端返回了无法解析的错误 | 500 |
InvalidArgument | 3 | 参数无效 | 400 |
DeadlineExceeded | 4 | 超时 | 504 |
NotFound | 5 | 资源不存在 | 404 |
AlreadyExists | 6 | 资源已存在 | 409 |
PermissionDenied | 7 | 无权限 | 403 |
ResourceExhausted | 8 | 资源耗尽(限流) | 429 |
FailedPrecondition | 9 | 前置条件不满足 | 400 |
Aborted | 10 | 并发冲突(可重试) | 409 |
OutOfRange | 11 | 超出有效范围 | 400 |
Unimplemented | 12 | 方法未实现 | 501 |
Internal | 13 | 内部错误 | 500 |
Unavailable | 14 | 服务不可用(可重试) | 503 |
DataLoss | 15 | 数据丢失 | 500 |
Unauthenticated | 16 | 未认证 | 401 |
go
package main
import (
"fmt"
"google.golang.org/grpc/codes"
)
func main() {
// 状态码是 int32 的别名
fmt.Printf("OK = %d\n", codes.OK)
fmt.Printf("NotFound = %d\n", codes.NotFound)
fmt.Printf("Internal = %d\n", codes.Internal)
// 状态码可比较
if codes.NotFound != codes.OK {
fmt.Println("NotFound is not OK")
}
// 状态码转字符串
fmt.Printf("code 14 = %s\n", codes.Code(14).String())
}状态码选择要点:
InvalidArgumentvsFailedPrecondition:前者是参数本身非法(如负数 ID),后者是参数合法但当前状态不允许(如订单已发货无法取消)。NotFoundvsPermissionDenied:前者是资源不存在,后者是资源存在但无权访问(注意:为防止信息泄露,某些场景应统一返回 NotFound)。AbortedvsFailedPrecondition:前者是并发冲突(如乐观锁失败,可重试),后者是状态前置条件不满足。Unavailable是唯一默认可重试的状态码。
二、status 包
google.golang.org/grpc/status 包是创建和解析 gRPC 错误的核心工具。
1. 创建错误
go
package main
import (
"fmt"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func main() {
// 方式一:status.Error 创建简单错误
err1 := status.Error(codes.NotFound, "user 42 not found")
fmt.Printf("err1: %v\n", err1)
// 方式二:status.Errorf 带格式化
err2 := status.Errorf(codes.InvalidArgument, "age %d is out of range [0, 150]", 200)
fmt.Printf("err2: %v\n", err2)
// 方式三:status.New 创建 Status 对象,再转 error
st := status.New(codes.PermissionDenied, "admin role required")
err3 := st.Err()
fmt.Printf("err3: %v\n", err3)
// 方式四:从已有 error 转换
err4 := status.Convert(fmt.Errorf("some error"))
fmt.Printf("err4 code: %v\n", err4.Code()) // 默认 Unknown
}2. 解析错误
go
package main
import (
"errors"
"fmt"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func main() {
// 模拟服务端返回的错误
err := status.Errorf(codes.NotFound, "order %d not found", 12345)
// 方式一:status.FromError 返回 Status + bool
st, ok := status.FromError(err)
if ok {
fmt.Printf("code: %v\n", st.Code())
fmt.Printf("message: %s\n", st.Message())
fmt.Printf("full: %v\n", st)
}
// 方式二:status.Code 直接提取状态码
code := status.Code(err)
fmt.Printf("code: %v\n", code)
// 非 gRPC 错误的 Code 是 Unknown
plainErr := errors.New("plain error")
fmt.Printf("plain code: %v\n", status.Code(plainErr))
// 判断特定状态码
if status.Code(err) == codes.NotFound {
fmt.Println("-> not found, use default value")
}
// errors.Is 与 gRPC 错误
wrapped := fmt.Errorf("wrap: %w", err)
fmt.Printf("wrapped code: %v\n", status.Code(wrapped))
fmt.Printf("is NotFound: %v\n", errors.Is(wrapped, status.Error(codes.NotFound, "")))
}三、错误详情:errdetails 包
简单的 code + message 不够用——你需要告诉客户端「哪个字段错了」「多久后重试」「需要什么权限」。gRPC 通过 错误详情(Error Details) 机制解决,由 google.golang.org/genproto/googleapis/rpc/errdetails 包提供。
1. ErrorInfo:通用错误信息
go
package main
import (
"fmt"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/genproto/googleapis/rpc/errdetails"
)
func main() {
// 创建带 ErrorInfo 详情的错误
st := status.New(codes.InvalidArgument, "request validation failed")
st, _ = st.WithDetails(&errdetails.ErrorInfo{
Reason: "INVALID_EMAIL_FORMAT",
Domain: "shop.example.com",
Metadata: map[string]string{
"field": "email",
"input": "alice@",
"pattern": "^[a-z]+@[a-z]+\\.[a-z]+$",
"doc_url": "https://docs.example.com/errors/invalid-email",
},
})
err := st.Err()
fmt.Printf("error: %v\n", err)
// 客户端解析详情
if st, ok := status.FromError(err); ok {
fmt.Printf("code: %v\n", st.Code())
fmt.Printf("message: %s\n", st.Message())
for _, d := range st.Details() {
switch info := d.(type) {
case *errdetails.ErrorInfo:
fmt.Printf("ErrorInfo: reason=%s domain=%s metadata=%v\n",
info.Reason, info.Domain, info.Metadata)
}
}
}
}2. BadRequest:字段级校验错误
go
package main
import (
"fmt"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/genproto/googleapis/rpc/errdetails"
)
func main() {
// 模拟表单校验:多个字段同时出错
st := status.New(codes.InvalidArgument, "multiple validation errors")
st, _ = st.WithDetails(&errdetails.BadRequest{
FieldViolations: []*errdetails.BadRequest_FieldViolation{
{
Field: "email",
Description: "must be a valid email address",
},
{
Field: "age",
Description: "must be between 0 and 150",
},
{
Field: "password",
Description: "must be at least 8 characters",
},
},
})
err := st.Err()
// 客户端解析字段级错误
if st, ok := status.FromError(err); ok {
for _, d := range st.Details() {
if br, ok := d.(*errdetails.BadRequest); ok {
for _, v := range br.FieldViolations {
fmt.Printf("field %q: %s\n", v.Field, v.Description)
}
}
}
}
}3. RetryInfo:重试建议
go
package main
import (
"fmt"
"time"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/durationpb"
)
func main() {
// 服务端告知客户端「限流了,2 秒后重试」
retryDelay := durationpb.New(2 * time.Second)
st := status.New(codes.ResourceExhausted, "rate limit exceeded")
st, _ = st.WithDetails(&errdetails.RetryInfo{
RetryDelay: retryDelay,
})
err := st.Err()
// 客户端解析重试延迟
if st, ok := status.FromError(err); ok {
for _, d := range st.Details() {
if ri, ok := d.(*errdetails.RetryInfo); ok {
delay := ri.RetryDelay.AsDuration()
fmt.Printf("should retry after %v\n", delay)
// 实际中:time.Sleep(delay) 然后 retry
}
}
}
}4. 其他常用 errdetails
| 类型 | 用途 |
|---|---|
QuotaFailure | 配额超限,附带哪些资源超限 |
PreconditionFailure | 前置条件不满足的详细列表 |
ErrorInfo | 通用错误原因、域名、元数据 |
BadRequest | 字段级校验错误 |
RetryInfo | 重试延迟建议 |
DebugInfo | 调试信息(堆栈、异常详情) |
LocalizedMessage | 本地化错误消息 |
go
package main
import (
"fmt"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func main() {
// QuotaFailure:配额超限
st1 := status.New(codes.ResourceExhausted, "quota exceeded")
st1, _ = st1.WithDetails(&errdetails.QuotaFailure{
Violations: []*errdetails.QuotaFailure_Violation{
{Subject: "api_calls_per_minute", Description: "exceeded 1000/min limit"},
{Subject: "storage_bytes", Description: "exceeded 10GB limit"},
},
})
fmt.Printf("quota error: %v\n", st1.Err())
// PreconditionFailure:前置条件失败
st2 := status.New(codes.FailedPrecondition, "precondition failed")
st2, _ = st2.WithDetails(&errdetails.PreconditionFailure{
Violations: []*errdetails.PreconditionFailure_Violation{
{Type: "TOS", Subject: "user.example.com", Description: "terms of service not accepted"},
{Type: "PAYMENT", Subject: "billing", Description: "no valid payment method"},
},
})
fmt.Printf("precondition error: %v\n", st2.Err())
// DebugInfo:调试信息(仅用于内部服务,不要暴露给外部)
st3 := status.New(codes.Internal, "database error")
st3, _ = st3.WithDetails(&errdetails.DebugInfo{
StackEntries: []string{
"main.processOrder() at /app/order.go:42",
"main.db.Query() at /app/db.go:128",
},
Detail: "connection refused to postgres:5432",
})
fmt.Printf("debug error: %v\n", st3.Err())
// LocalizedMessage:多语言错误消息
st4 := status.New(codes.InvalidArgument, "invalid input")
st4, _ = st4.WithDetails(&errdetails.LocalizedMessage{
Locale: "zh-CN",
Message: "输入无效,请检查后重试",
})
fmt.Printf("localized error: %v\n", st4.Err())
}四、自定义错误信息
当 errdetails 不满足需求时,可以用 anypb.Any 携带自定义的错误详情。
go
package main
import (
"fmt"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/anypb"
)
// 自定义错误详情(模拟 protoc 生成的消息)
type BusinessErrorDetail struct {
Code string
Severity string // "INFO", "WARNING", "ERROR", "FATAL"
Timestamp int64
Context map[string]string
}
func main() {
// 构造自定义详情
detail := &BusinessErrorDetail{
Code: "INSUFFICIENT_BALANCE",
Severity: "ERROR",
Timestamp: 1700000000,
Context: map[string]string{
"user_id": "user-42",
"required": "100.00",
"actual": "30.00",
"currency": "CNY",
},
}
// 注意:真实项目中 detail 需要是 proto.Message
// 这里用字符串模拟序列化
st := status.Newf(codes.FailedPrecondition,
"insufficient balance: need %.2f, have %.2f", 100.00, 30.00)
// 实际使用 WithDetails 时需要传 proto.Message
// st, _ = st.WithDetails(detail) // detail 需实现 proto.Message
fmt.Printf("business error: %v\n", st.Err())
fmt.Printf("detail: %+v\n", detail)
// 用 Any 携带任意 proto 消息的示例
anyErr := &anypb.Any{
TypeUrl: "type.googleapis.com/shop.v1.BusinessErrorDetail",
Value: []byte("serialized-detail"),
}
fmt.Printf("any detail type: %s\n", anyErr.TypeUrl)
}五、错误传播与客户端处理
go
package main
import (
"context"
"fmt"
"log"
"time"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// 模拟服务端返回 rich error
func serverHandler(ctx context.Context, userID string) (string, error) {
if userID == "" {
st := status.New(codes.InvalidArgument, "user_id is required")
st, _ = st.WithDetails(&errdetails.BadRequest{
FieldViolations: []*errdetails.BadRequest_FieldViolation{
{Field: "user_id", Description: "must not be empty"},
},
})
return "", st.Err()
}
if userID == "unknown" {
return "", status.Errorf(codes.NotFound, "user %q not found", userID)
}
return "user-data-" + userID, nil
}
// 客户端处理函数:区分可重试和不可重试错误
func clientCall(ctx context.Context, userID string) {
result, err := serverHandler(ctx, userID)
if err != nil {
st, ok := status.FromError(err)
if !ok {
log.Printf("non-grpc error: %v", err)
return
}
switch st.Code() {
case codes.InvalidArgument:
// 参数错误:不可重试,需修正参数
fmt.Printf("[client] invalid argument: %s\n", st.Message())
for _, d := range st.Details() {
if br, ok := d.(*errdetails.BadRequest); ok {
for _, v := range br.FieldViolations {
fmt.Printf(" field %q: %s\n", v.Field, v.Description)
}
}
}
case codes.NotFound:
// 资源不存在:不可重试,走降级逻辑
fmt.Printf("[client] not found: %s (using default)\n", st.Message())
case codes.Unavailable:
// 服务不可用:可重试
fmt.Printf("[client] unavailable: %s (will retry)\n", st.Message())
case codes.DeadlineExceeded:
// 超时:可重试
fmt.Printf("[client] timeout: %s\n", st.Message())
case codes.Unauthenticated:
// 未认证:刷新 token 后重试
fmt.Printf("[client] unauthenticated: %s (refreshing token)\n", st.Message())
case codes.PermissionDenied:
// 无权限:不可重试
fmt.Printf("[client] permission denied: %s\n", st.Message())
default:
fmt.Printf("[client] error code=%v: %s\n", st.Code(), st.Message())
}
return
}
fmt.Printf("[client] success: %s\n", result)
}
func main() {
ctx := context.Background()
fmt.Println("--- empty user_id ---")
clientCall(ctx, "")
fmt.Println("\n--- unknown user ---")
clientCall(ctx, "unknown")
fmt.Println("\n--- valid user ---")
clientCall(ctx, "user-42")
_ = time.Second
}六、rich error 模式实战
下面展示一个服务端如何根据不同业务场景返回丰富的错误信息。
go
package main
import (
"context"
"fmt"
"time"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/durationpb"
)
// 业务场景:创建订单
type CreateOrderRequest struct {
UserID string
ProductID string
Quantity int32
Address string
}
func createOrder(ctx context.Context, req *CreateOrderRequest) (string, error) {
// 场景1:参数校验失败
var violations []*errdetails.BadRequest_FieldViolation
if req.UserID == "" {
violations = append(violations, &errdetails.BadRequest_FieldViolation{
Field: "user_id", Description: "must not be empty",
})
}
if req.Quantity <= 0 {
violations = append(violations, &errdetails.BadRequest_FieldViolation{
Field: "quantity", Description: "must be positive",
})
}
if req.Quantity > 100 {
violations = append(violations, &errdetails.BadRequest_FieldViolation{
Field: "quantity", Description: "must not exceed 100 per order",
})
}
if len(violations) > 0 {
st := status.New(codes.InvalidArgument, "validation failed")
st, _ = st.WithDetails(&errdetails.BadRequest{FieldViolations: violations})
return "", st.Err()
}
// 场景2:商品不存在
if req.ProductID == "nonexistent" {
st := status.New(codes.NotFound, "product not found")
st, _ = st.WithDetails(&errdetails.ErrorInfo{
Reason: "PRODUCT_NOT_FOUND",
Domain: "shop.example.com",
Metadata: map[string]string{
"product_id": req.ProductID,
"suggest": "check product catalog",
},
})
return "", st.Err()
}
// 场景3:库存不足,附带重试建议
if req.Quantity > 50 {
st := status.New(codes.ResourceExhausted, "insufficient stock")
st, _ = st.WithDetails(&errdetails.RetryInfo{
RetryDelay: durationpb.New(30 * time.Second),
})
st, _ = st.WithDetails(&errdetails.ErrorInfo{
Reason: "LOW_STOCK",
Metadata: map[string]string{
"available": "50",
"requested": fmt.Sprintf("%d", req.Quantity),
},
})
return "", st.Err()
}
// 场景4:用户未验证邮箱(前置条件不满足)
if req.UserID == "unverified" {
st := status.New(codes.FailedPrecondition, "email not verified")
st, _ = st.WithDetails(&errdetails.PreconditionFailure{
Violations: []*errdetails.PreconditionFailure_Violation{
{
Type: "EMAIL_VERIFICATION",
Subject: "user.example.com",
Description: "please verify your email before placing orders",
},
},
})
return "", st.Err()
}
// 场景5:成功
return fmt.Sprintf("order-%s-%d", req.ProductID, time.Now().Unix()), nil
}
func main() {
cases := []struct {
name string
req *CreateOrderRequest
}{
{"empty user + bad quantity", &CreateOrderRequest{UserID: "", Quantity: -1}},
{"product not found", &CreateOrderRequest{UserID: "u1", ProductID: "nonexistent", Quantity: 1}},
{"low stock", &CreateOrderRequest{UserID: "u1", ProductID: "p1", Quantity: 99}},
{"unverified user", &CreateOrderRequest{UserID: "unverified", ProductID: "p1", Quantity: 1}},
{"success", &CreateOrderRequest{UserID: "u1", ProductID: "p1", Quantity: 5}},
}
for _, c := range cases {
fmt.Printf("\n=== %s ===\n", c.name)
orderID, err := createOrder(context.Background(), c.req)
if err != nil {
st, _ := status.FromError(err)
fmt.Printf("code: %v\n", st.Code())
fmt.Printf("message: %s\n", st.Message())
for _, d := range st.Details() {
switch detail := d.(type) {
case *errdetails.BadRequest:
for _, v := range detail.FieldViolations {
fmt.Printf(" BadRequest: field=%s desc=%s\n", v.Field, v.Description)
}
case *errdetails.ErrorInfo:
fmt.Printf(" ErrorInfo: reason=%s metadata=%v\n", detail.Reason, detail.Metadata)
case *errdetails.RetryInfo:
fmt.Printf(" RetryInfo: retry_after=%v\n", detail.RetryDelay.AsDuration())
case *errdetails.PreconditionFailure:
for _, v := range detail.Violations {
fmt.Printf(" Precondition: type=%s desc=%s\n", v.Type, v.Description)
}
}
}
} else {
fmt.Printf("success: orderID=%s\n", orderID)
}
}
}七、错误映射:HTTP 状态码与 gRPC 状态码
gRPC-Gateway 和 REST 代理需要将 gRPC 状态码映射为 HTTP 状态码。标准映射关系如下:
| gRPC 状态码 | HTTP 状态码 | HTTP 方法约束 |
|---|---|---|
OK | 200 | All |
Canceled | 499 | All |
Unknown | 500 | All |
InvalidArgument | 400 | All |
DeadlineExceeded | 504 | All |
NotFound | 404 | All |
AlreadyExists | 409 | POST/PUT |
PermissionDenied | 403 | All |
ResourceExhausted | 429 | All |
FailedPrecondition | 400 | All |
Aborted | 409 | All |
OutOfRange | 400 | All |
Unimplemented | 501 | All |
Internal | 500 | All |
Unavailable | 503 | All |
DataLoss | 500 | All |
Unauthenticated | 401 | All |
go
package main
import (
"fmt"
"net/http"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// gRPC 状态码 -> HTTP 状态码
func grpcCodeToHTTP(code codes.Code) int {
switch code {
case codes.OK:
return http.StatusOK
case codes.Canceled:
return 499 // Client Closed Request
case codes.Unknown:
return http.StatusInternalServerError
case codes.InvalidArgument:
return http.StatusBadRequest
case codes.DeadlineExceeded:
return http.StatusGatewayTimeout
case codes.NotFound:
return http.StatusNotFound
case codes.AlreadyExists:
return http.StatusConflict
case codes.PermissionDenied:
return http.StatusForbidden
case codes.ResourceExhausted:
return http.StatusTooManyRequests
case codes.FailedPrecondition:
return http.StatusBadRequest
case codes.Aborted:
return http.StatusConflict
case codes.OutOfRange:
return http.StatusBadRequest
case codes.Unimplemented:
return http.StatusNotImplemented
case codes.Internal:
return http.StatusInternalServerError
case codes.Unavailable:
return http.StatusServiceUnavailable
case codes.DataLoss:
return http.StatusInternalServerError
case codes.Unauthenticated:
return http.StatusUnauthorized
default:
return http.StatusInternalServerError
}
}
// HTTP 状态码 -> gRPC 状态码(反向映射,用于 REST 客户端调用 gRPC)
func httpToGRPCCode(httpCode int) codes.Code {
switch httpCode {
case http.StatusOK:
return codes.OK
case http.StatusBadRequest:
return codes.InvalidArgument
case http.StatusUnauthorized:
return codes.Unauthenticated
case http.StatusForbidden:
return codes.PermissionDenied
case http.StatusNotFound:
return codes.NotFound
case http.StatusConflict:
return codes.AlreadyExists
case http.StatusTooManyRequests:
return codes.ResourceExhausted
case http.StatusNotImplemented:
return codes.Unimplemented
case http.StatusServiceUnavailable:
return codes.Unavailable
case http.StatusGatewayTimeout:
return codes.DeadlineExceeded
case http.StatusInternalServerError:
return codes.Internal
default:
return codes.Unknown
}
}
func main() {
// 测试正向映射
testCodes := []codes.Code{
codes.OK, codes.NotFound, codes.InvalidArgument,
codes.Unauthenticated, codes.ResourceExhausted, codes.Internal,
}
for _, c := range testCodes {
fmt.Printf("%-20s -> HTTP %d\n", c.String(), grpcCodeToHTTP(c))
}
fmt.Println()
// 测试反向映射
testHTTP := []int{200, 400, 401, 403, 404, 429, 500, 503}
for _, h := range testHTTP {
fmt.Printf("HTTP %-4d -> %s\n", h, httpToGRPCCode(h).String())
}
// 演示在 HTTP handler 中包装 gRPC 错误
err := status.Error(codes.NotFound, "user not found")
httpCode := grpcCodeToHTTP(status.Code(err))
fmt.Printf("\nHTTP response: %d %s\n", httpCode, http.StatusText(httpCode))
}八、错误日志与追踪
错误应该携带足够的上下文信息,便于排查问题,同时不泄露敏感信息。
go
package main
import (
"context"
"fmt"
"log"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
// 错误日志结构体
type ErrorLog struct {
Timestamp string `json:"timestamp"`
Method string `json:"method"`
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
UserID string `json:"user_id,omitempty"`
StackTrace string `json:"stack_trace,omitempty"`
Context map[string]string `json:"context,omitempty"`
}
func logError(ctx context.Context, method string, err error) {
st, _ := status.FromError(err)
errorLog := ErrorLog{
Timestamp: time.Now().Format(time.RFC3339Nano),
Method: method,
Code: st.Code().String(),
Message: st.Message(),
Context: make(map[string]string),
}
// 从 context 提取 request-id
if md, ok := metadata.FromIncomingContext(ctx); ok {
if v := md.Get("x-request-id"); len(v) > 0 {
errorLog.RequestID = v[0]
}
}
// 从 context 提取 user-id
if uid, ok := ctx.Value("userID").(string); ok {
errorLog.UserID = uid
}
// 根据错误码决定日志级别和是否记录堆栈
switch st.Code() {
case codes.Internal, codes.Unknown, codes.DataLoss:
// 严重错误:记录完整堆栈,触发告警
errorLog.StackTrace = "goroutine 1 [running]:\nmain.something()\n\t/app/main.go:42"
log.Printf("[ERROR] %+v", errorLog)
// 实际项目中:发送到 Sentry/Prometheus Alert
case codes.Unavailable, codes.DeadlineExceeded:
// 基础设施错误:记录,可能触发告警
log.Printf("[WARN] %+v", errorLog)
case codes.InvalidArgument, codes.NotFound, codes.AlreadyExists:
// 客户端错误:记录为 INFO,不告警
log.Printf("[INFO] %+v", errorLog)
default:
log.Printf("[INFO] %+v", errorLog)
}
}
func main() {
ctx := metadata.NewIncomingContext(context.Background(),
metadata.Pairs("x-request-id", "req-log-001"))
ctx = context.WithValue(ctx, "userID", "user-42")
// 模拟不同类型的错误
errors := []struct {
method string
err error
}{
{"/shop.OrderService/GetOrder", status.Error(codes.NotFound, "order 123 not found")},
{"/shop.OrderService/CreateOrder", status.Error(codes.InvalidArgument, "quantity must be positive")},
{"/shop.OrderService/Pay", status.Error(codes.Internal, "database connection lost")},
{"/shop.OrderService/Cancel", status.Error(codes.Unavailable, "payment service unavailable")},
}
for _, e := range errors {
fmt.Printf("\n--- %s ---\n", e.method)
logError(ctx, e.method, e.err)
}
}九、完整示例:带丰富错误信息的用户服务
go
package main
import (
"context"
"fmt"
"log"
"regexp"
"strings"
"time"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/durationpb"
)
// === 数据模型 ===
type User struct {
ID string
Name string
Email string
Age int32
Role string
}
// === 模拟存储 ===
var userStore = map[string]*User{
"u1": {ID: "u1", Name: "alice", Email: "alice@example.com", Age: 30, Role: "user"},
"u2": {ID: "u2", Name: "bob", Email: "bob@example.com", Age: 25, Role: "admin"},
}
// === 请求类型 ===
type CreateUserRequest struct {
Name string
Email string
Age int32
}
// === 服务实现 ===
type UserService struct{}
var emailRegex = regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$`)
func (s *UserService) CreateUser(ctx context.Context, req *CreateUserRequest) (*User, error) {
// 1. 参数校验
var violations []*errdetails.BadRequest_FieldViolation
if len(req.Name) < 2 {
violations = append(violations, &errdetails.BadRequest_FieldViolation{
Field: "name", Description: "must be at least 2 characters",
})
}
if !emailRegex.MatchString(req.Email) {
violations = append(violations, &errdetails.BadRequest_FieldViolation{
Field: "email", Description: "must be a valid email address",
})
}
if req.Age < 0 || req.Age > 150 {
violations = append(violations, &errdetails.BadRequest_FieldViolation{
Field: "age", Description: "must be between 0 and 150",
})
}
if len(violations) > 0 {
st := status.New(codes.InvalidArgument, "validation failed")
st, _ = st.WithDetails(&errdetails.BadRequest{FieldViolations: violations})
return nil, st.Err()
}
// 2. 唯一性检查
for _, existing := range userStore {
if strings.EqualFold(existing.Email, req.Email) {
st := status.New(codes.AlreadyExists, "email already registered")
st, _ = st.WithDetails(&errdetails.ErrorInfo{
Reason: "EMAIL_DUPLICATE",
Domain: "user.example.com",
Metadata: map[string]string{
"email": req.Email,
"existing_user_id": existing.ID,
},
})
return nil, st.Err()
}
}
// 3. 创建用户
user := &User{
ID: fmt.Sprintf("u%d", len(userStore)+1),
Name: req.Name,
Email: req.Email,
Age: req.Age,
Role: "user",
}
userStore[user.ID] = user
return user, nil
}
func (s *UserService) GetUser(ctx context.Context, userID string) (*User, error) {
user, ok := userStore[userID]
if !ok {
st := status.New(codes.NotFound, "user not found")
st, _ = st.WithDetails(&errdetails.ErrorInfo{
Reason: "USER_NOT_FOUND",
Domain: "user.example.com",
Metadata: map[string]string{
"user_id": userID,
"hint": "use ListUsers to see all available users",
},
})
return nil, st.Err()
}
return user, nil
}
func (s *UserService) DeleteUser(ctx context.Context, userID, requesterRole string) error {
if requesterRole != "admin" {
st := status.New(codes.PermissionDenied, "admin role required")
st, _ = st.WithDetails(&errdetails.ErrorInfo{
Reason: "INSUFFICIENT_ROLE",
Metadata: map[string]string{
"required_role": "admin",
"actual_role": requesterRole,
"user_id": userID,
},
})
return st.Err()
}
if _, ok := userStore[userID]; !ok {
return status.Errorf(codes.NotFound, "user %s not found", userID)
}
delete(userStore, userID)
return nil
}
// === 客户端错误处理 ===
func printError(method string, err error) {
st, ok := status.FromError(err)
if !ok {
fmt.Printf("[%s] non-grpc error: %v\n", method, err)
return
}
fmt.Printf("[%s] code=%s message=%s\n", method, st.Code(), st.Message())
for _, d := range st.Details() {
switch detail := d.(type) {
case *errdetails.BadRequest:
for _, v := range detail.FieldViolations {
fmt.Printf(" -> field %q: %s\n", v.Field, v.Description)
}
case *errdetails.ErrorInfo:
fmt.Printf(" -> reason=%s metadata=%v\n", detail.Reason, detail.Metadata)
case *errdetails.RetryInfo:
fmt.Printf(" -> retry after %v\n", detail.RetryDelay.AsDuration())
}
}
}
func main() {
svc := &UserService{}
ctx := context.Background()
// 场景1:参数校验失败
fmt.Println("=== Create: validation failed ===")
_, err := svc.CreateUser(ctx, &CreateOrderRequestWrapper().Request)
printError("CreateUser", err)
// 场景2:邮箱重复
fmt.Println("\n=== Create: email duplicate ===")
_, err = svc.CreateUser(ctx, &CreateUserRequest{Name: "alice2", Email: "alice@example.com", Age: 20})
printError("CreateUser", err)
// 场景3:成功创建
fmt.Println("\n=== Create: success ===")
user, err := svc.CreateUser(ctx, &CreateUserRequest{Name: "charlie", Email: "charlie@example.com", Age: 28})
if err != nil {
printError("CreateUser", err)
} else {
fmt.Printf("created user: %+v\n", user)
}
// 场景4:用户不存在
fmt.Println("\n=== Get: not found ===")
_, err = svc.GetUser(ctx, "nonexistent")
printError("GetUser", err)
// 场景5:权限不足
fmt.Println("\n=== Delete: permission denied ===")
err = svc.DeleteUser(ctx, "u1", "user")
printError("DeleteUser", err)
// 场景6:成功删除
fmt.Println("\n=== Delete: success ===")
err = svc.DeleteUser(ctx, "u2", "admin")
if err != nil {
printError("DeleteUser", err)
} else {
fmt.Println("user deleted successfully")
}
_ = time.Second
_ = durationpb.New
_ = log.Printf
}
// 辅助函数:构造一个校验失败的请求
func CreateOrderRequestWrapper() struct{ Request *CreateUserRequest } {
return struct{ Request *CreateUserRequest }{
Request: &CreateUserRequest{Name: "x", Email: "invalid", Age: 200},
}
}十、小结
本篇深入 gRPC 的错误处理与状态码体系:
- 状态码体系:17 个标准状态码,每个都有明确的语义和 HTTP 映射,选择正确状态码是良好 API 设计的基础。
- status 包:
status.New/status.Error/status.Errorf创建错误,status.FromError/status.Code解析错误。 - 错误详情(errdetails):通过
WithDetails携带结构化的错误信息——BadRequest(字段级校验)、ErrorInfo(原因+元数据)、RetryInfo(重试建议)、QuotaFailure(配额超限)、PreconditionFailure(前置条件)等。 - 自定义错误:用
anypb.Any携带业务自定义的错误详情 proto 消息。 - 错误传播:客户端通过
status.FromError解析,按状态码分类处理(可重试 vs 不可重试 vs 降级)。 - HTTP 映射:gRPC 状态码与 HTTP 状态码的双向映射表,用于 gRPC-Gateway 等代理层。
- 错误日志:按状态码分级记录(Internal 严重告警、Unavailable 基础设施告警、InvalidArgument 仅记录),携带 request-id 和上下文。
- 完整示例:用户服务综合运用了字段校验、唯一性检查、权限控制、资源不存在等场景的 rich error。
下一篇将学习 gRPC-Gateway,将 gRPC 服务自动转换为 RESTful API。