承接的问题
1.1 把一次调用拆成了五个语义要素,其中「错误」只说了落在 trailers。真实系统里,错误表达不清的代价比超时更大:超时可以重试,错误判错会把「书不存在」当成「服务挂了」,触发一轮没有意义的重试;或者把「库存冲突」当成永久失败,丢掉本可以成功的请求。
这一节解决三个问题:错误码用哪一层表达、哪些错误可以重试、服务端和客户端怎么用同一套类型把它传过去。答案不是「定义一个 code 字段」,而是三层结构:Status.Code 给粗分类,ErrorInfo.reason 给稳定原因,message 给人看。
Status 的十七个码
gRPC 的 Status.Code 是固定枚举,不能扩展。它的人数不多,但每一个都有明确语义:
| 码 | 数值 | 含义 | 典型场景 |
|---|---|---|---|
OK | 0 | 成功 | 正常返回 |
CANCELLED | 1 | 调用方主动取消 | 客户端超时、用户离开页面 |
UNKNOWN | 2 | 未知错误 | 未捕获异常、无法归类 |
INVALID_ARGUMENT | 3 | 参数不合法 | ISBN 格式错误、分页大小为负 |
DEADLINE_EXCEEDED | 4 | 超过 deadline | 下游慢、排队过久 |
NOT_FOUND | 5 | 资源不存在 | 书不存在、租户不存在 |
ALREADY_EXISTS | 6 | 资源已存在 | 重复创建、唯一键冲突 |
PERMISSION_DENIED | 7 | 无权限 | token 无效、租户越权 |
RESOURCE_EXHAUSTED | 8 | 资源耗尽 | 限流、配额、消息过大 |
FAILED_PRECONDITION | 9 | 前置条件不满足 | 状态机不允许当前操作 |
ABORTED | 10 | 并发冲突 | 乐观锁失败、库存版本冲突 |
OUT_OF_RANGE | 11 | 越界 | 分页偏移超出范围 |
UNIMPLEMENTED | 12 | 方法未实现 | 版本不匹配、方法名写错 |
INTERNAL | 13 | 内部错误 | 不该发生的 bug、序列化失败 |
UNAVAILABLE | 14 | 服务不可用 | 连接失败、下游宕机、过载 |
DATA_LOSS | 15 | 数据丢失或损坏 | 解码失败、存储损坏 |
UNAUTHENTICATED | 16 | 未认证 | 缺少 token、token 过期 |
这张表最重要的不是背下来,而是分清三组:
- 调用方的问题:
INVALID_ARGUMENT、NOT_FOUND、ALREADY_EXISTS、PERMISSION_DENIED、FAILED_PRECONDITION、OUT_OF_RANGE、UNAUTHENTICATED。重试同样的请求不会变好,除非请求内容或权限变了。 - 服务端/环境的问题:
UNAVAILABLE、RESOURCE_EXHAUSTED、DEADLINE_EXCEEDED、ABORTED。可能通过退避、换实例、降低并发变好。 - 不确定的问题:
UNKNOWN、INTERNAL、DATA_LOSS。默认不重试,除非业务能证明操作幂等且错误是瞬时的。
UNKNOWN 是一个失败信号,不是分类。服务端应该尽量把已知错误映射到更具体的码;只有真的无法归类时才用 UNKNOWN,并在日志里保留原始异常。
三层错误表达
同一个「书不存在」,在三个层次上各有表达:
| 层 | 载体 | 作用 | 是否稳定 |
|---|---|---|---|
| 状态码 | trailers 的 grpc-status | 粗分类,决定重试、熔断、HTTP 映射 | 固定枚举,稳定 |
| 原因码 | ErrorInfo.reason | 业务语义,程序据此分支 | 字符串,需版本管理 |
| 描述 | google.rpc.Status.message / grpc-message | 给人看,进日志 | 可改,不参与判断 |
ErrorInfo 定义在 google.rpc 包里,通过 grpc-status-details-bin 这个二进制 metadata 随 trailers 传输。它的结构是:
message ErrorInfo {
string reason = 1;
string domain = 2;
map<string, string> metadata = 3;
}
reason 是稳定的机器可读字符串,domain 用来区分来源(例如 linkd.v3、libraryd),metadata 放定位信息(ISBN、租户、版本)。不要把 ISBN 拼进 message 再让客户端正则提取;字符串格式一改,所有解析都会坏。
google.rpc.Status.details 是 repeated Any,所以 ErrorInfo 要先 Any.pack 再放进去。io.grpc.protobuf.StatusProto 负责把整个 google.rpc.Status 序列化进 trailers,并在客户端还原。这一层不需要自己拼 metadata 键。
linkd-v3 的六个原因码
项目只定义六个原因码,覆盖当前业务。新增原因码要走契约评审,不能随手加字符串:
| reason | Status.Code | 可重试 | 什么时候用 |
|---|---|---|---|
BOOK_NOT_FOUND | NOT_FOUND | 否 | ISBN 在库中不存在 |
STOCK_CONFLICT | ABORTED | 是 | 库存版本冲突,调用方可重读后重试 |
RATE_LIMITED | RESOURCE_EXHAUSTED | 是 | 触发限流,按 RetryInfo 退避 |
DEADLINE_EXCEEDED | DEADLINE_EXCEEDED | 否 | 本次调用预算耗尽,重试只会更慢 |
UPSTREAM_UNAVAILABLE | UNAVAILABLE | 是 | 下游不可达、连接被拒 |
TENANT_FORBIDDEN | PERMISSION_DENIED | 否 | 租户越权,重试无意义 |
这张表有两个不显然的决定。
第一,DEADLINE_EXCEEDED 标为不可重试。总 deadline 是调用方给的,超时说明预算已经花完;在原预算内再试一次只会挤占后面的时间。真正的重试应该发生在还有剩余预算时,由 3.3 的重试策略决定,而不是看到超时就无脑重来。
第二,STOCK_CONFLICT 标为可重试,但重试前必须重新读取库存版本。ABORTED 的语义是「并发冲突,可以在更高层重试」,不是「原样重发」。3.3 会把这条规则写进幂等键的处理里。
在 linkd-v3 里落地:RpcError
RpcError 是业务层唯一的错误类型。它不依赖 gRPC 的传输细节,只在边界处转换成 StatusRuntimeException:
package com.plume.linkd.v3.core.error;
import com.google.protobuf.Any;
import com.google.rpc.ErrorInfo;
import com.plume.linkd.v3.api.ErrorReasons;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.grpc.protobuf.StatusProto;
import java.util.Map;
import java.util.Set;
public record RpcError(Status.Code code, String reason, String message, boolean retryable) {
public static final String DOMAIN = "linkd.v3";
private static final Set<String> RETRYABLE_REASONS = Set.of(
ErrorReasons.STOCK_CONFLICT, ErrorReasons.RATE_LIMITED,
ErrorReasons.UPSTREAM_UNAVAILABLE);
public static RpcError bookNotFound(String isbn) {
return new RpcError(Status.Code.NOT_FOUND, ErrorReasons.BOOK_NOT_FOUND,
"book not found: " + isbn, false);
}
public static RpcError stockConflict(String isbn, long expectedVersion) {
return new RpcError(Status.Code.ABORTED, ErrorReasons.STOCK_CONFLICT,
"stock version conflict: " + expectedVersion, true);
}
public static RpcError rateLimited(int retryAfterMillis) {
return new RpcError(Status.Code.RESOURCE_EXHAUSTED, ErrorReasons.RATE_LIMITED,
"rate limited, retry after " + retryAfterMillis + " ms", true);
}
public static RpcError upstreamUnavailable(String target, Throwable cause) {
return new RpcError(Status.Code.UNAVAILABLE, ErrorReasons.UPSTREAM_UNAVAILABLE,
"upstream " + target + " unavailable: " + cause.getClass().getSimpleName(), true);
}
public StatusRuntimeException toException(Map<String, String> metadata) {
ErrorInfo.Builder info = ErrorInfo.newBuilder()
.setReason(reason)
.setDomain(DOMAIN);
metadata.forEach(info::putMetadata);
com.google.rpc.Status status = com.google.rpc.Status.newBuilder()
.setCode(code.value())
.setMessage(message)
.addDetails(Any.pack(info.build()))
.build();
return StatusProto.toStatusRuntimeException(status);
}
public static RpcError from(Throwable throwable) {
com.google.rpc.Status status = StatusProto.fromThrowable(throwable);
if (status != null) {
for (Any detail : status.getDetailsList()) {
if (detail.is(ErrorInfo.class)) {
try {
ErrorInfo info = detail.unpack(ErrorInfo.class);
Status.Code code = Status.fromCodeValue(status.getCode()).getCode();
boolean retryable = RETRYABLE_REASONS.contains(info.getReason());
return new RpcError(code, info.getReason(), status.getMessage(), retryable);
} catch (com.google.protobuf.InvalidProtocolBufferException ignored) {
break;
}
}
}
}
Status fallback = Status.fromThrowable(throwable);
String description = fallback.getDescription() == null ? "" : fallback.getDescription();
return new RpcError(fallback.getCode(), fallback.getCode().name(), description,
fallback.getCode() == Status.Code.UNAVAILABLE);
}
}
几个设计点:
retryable由原因码推导,而不是由调用方随便传。from用同一张RETRYABLE_REASONS表还原,保证服务端和客户端的判断一致。message只放人能读的信息,不放结构化数据。ISBN、版本号放metadata。from在拿不到ErrorInfo时回退到Status的 code 和 description,不会因为对方没带 details 就抛第二个异常。Any.pack是google.rpc.Status的容器要求,不是可选的装饰。没有它,ErrorInfo进不了 trailers。
服务端抛错和客户端还原的用法如下:
package com.plume.linkd.v3.demo;
import com.plume.linkd.v3.api.Book;
import com.plume.linkd.v3.api.GetBookRequest;
import com.plume.linkd.v3.api.LibraryMethods;
import com.plume.linkd.v3.core.error.RpcError;
import io.grpc.CallOptions;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import io.grpc.Server;
import io.grpc.ServerBuilder;
import io.grpc.ServerServiceDefinition;
import io.grpc.StatusRuntimeException;
import io.grpc.stub.ClientCalls;
import io.grpc.stub.ServerCalls;
import java.util.Map;
public final class ErrorRoundTrip {
public static void main(String[] args) throws Exception {
ServerServiceDefinition service = ServerServiceDefinition.builder("plume.linkd.v3.Library")
.addMethod(LibraryMethods.GET_BOOK, ServerCalls.asyncUnaryCall((request, observer) -> {
if (!"42".equals(request.getIsbn())) {
observer.onError(RpcError.bookNotFound(request.getIsbn())
.toException(Map.of("isbn", request.getIsbn())));
return;
}
observer.onNext(Book.newBuilder().setIsbn("42").setTitle("book:42").build());
observer.onCompleted();
}))
.build();
Server server = ServerBuilder.forPort(0).addService(service).build().start();
ManagedChannel channel = ManagedChannelBuilder
.forAddress("127.0.0.1", server.getPort())
.usePlaintext()
.build();
try {
try {
ClientCalls.blockingUnaryCall(channel, LibraryMethods.GET_BOOK,
CallOptions.DEFAULT, GetBookRequest.newBuilder().setIsbn("99").build());
} catch (StatusRuntimeException e) {
RpcError error = RpcError.from(e);
System.out.println("code=" + error.code());
System.out.println("reason=" + error.reason());
System.out.println("retryable=" + error.retryable());
}
} finally {
channel.shutdownNow();
server.shutdownNow();
}
}
}
预期输出:
code=NOT_FOUND
reason=BOOK_NOT_FOUND
retryable=false
如果没有 ErrorInfo,客户端只能拿到 code=NOT_FOUND 和一段 grpc-message,无法区分「书不存在」和「租户下没有这本书」。原因码的价值就在这里:它把「怎么处理」从字符串解析变成了字段比较。
重试信息与限流提示
RESOURCE_EXHAUSTED 经常需要告诉调用方「多久以后再来」。google.rpc.RetryInfo 就是为此设计的,它带一个 retry_delay,由服务端给出建议退避时间。客户端把它和 7.1 的退避策略合并:取两者较大的值,再用 ±20% 抖动打散。
RATE_LIMITED + RetryInfo.retry_delay = 0.2s
客户端退避 = max(本地计算 20ms, 服务端建议 200ms) × jitter(0.8–1.2)
不要把所有 RESOURCE_EXHAUSTED 都当成限流。消息超过 4 MiB 也是这个码,但重试不会让消息变小;服务端应该在 ErrorInfo.metadata 里区分 reason=RATE_LIMITED 和 reason=MESSAGE_TOO_LARGE,客户端据此决定退避还是改请求。
错误模型与 HTTP 网关的映射
comm-gateway 最终要把 gRPC 错误转成 HTTP。映射不是一对一的,关键是保留原因码:
| gRPC code | HTTP 状态 | 响应体 |
|---|---|---|
INVALID_ARGUMENT | 400 | {"code":"INVALID_ARGUMENT","reason":"...","message":"..."} |
UNAUTHENTICATED | 401 | 同上 |
PERMISSION_DENIED | 403 | 同上 |
NOT_FOUND | 404 | 同上 |
ALREADY_EXISTS / ABORTED | 409 | 同上 |
FAILED_PRECONDITION | 412 | 同上 |
RESOURCE_EXHAUSTED | 429 | 附带 Retry-After |
DEADLINE_EXCEEDED | 504 | 同上 |
UNAVAILABLE | 503 | 同上 |
| 其他 | 500 | 隐藏内部细节 |
网关不要把所有错误都转成 500。500 会让调用方以为是网关 bug,触发错误的告警路由;429 和 503 才能让上游的限流和重试策略生效。9.3 会给出完整的映射实现。
常见错误与边界
错误一:用 message 判断错误类型。 if (e.getMessage().contains("not found")) 在文案改动、语言切换、代理截断后都会失效。判断用 reason,展示用 message。
错误二:所有失败都返回 UNKNOWN。 这等于放弃错误模型。熔断器无法区分「下游挂了」和「参数错了」,会把客户端错误也计入失败率,提前打开熔断。
错误三:重试全部错误。 INVALID_ARGUMENT、PERMISSION_DENIED、NOT_FOUND 重试一百次结果一样,只会放大负载。重试只针对 retryable=true 的原因码。
错误四:把 HTTP 状态码直接塞进 ErrorInfo.reason。 reason="404" 既不稳定也不可读,而且和 Status.Code 重复。原因码应该是业务词,例如 BOOK_NOT_FOUND。
错误五:在 trailers 里放敏感信息。 ErrorInfo.metadata 会经过代理、日志和追踪系统。token、密码、完整身份证号不能放进去;需要定位时放请求 ID,让有权限的人去查日志。
边界:trailers-only 响应没有 DATA。 服务端在还没发消息时就失败,gRPC 会发送一个只带 HEADERS + trailers 的响应。客户端仍然会收到 StatusRuntimeException,但抓包时看不到 DATA 帧,不要因此判断「请求没发出去」。
边界:错误详情也受消息大小限制。 grpc-status-details-bin 是 metadata,过大的 ErrorInfo 可能被代理截断或拒绝。details 控制在几 KB 以内,大对象放日志或对象存储。
边界:跨语言时 Any 的类型 URL 必须一致。 type.googleapis.com/google.rpc.ErrorInfo 是标准类型;如果自定义 proto 放进 details,接收方必须有同一份定义,否则 unpack 会失败。项目只使用 ErrorInfo 和 RetryInfo,避免自定义 details。
小结
Status.Code是固定枚举,负责粗分类;ErrorInfo.reason是稳定字符串,负责业务语义;message只给人看。- 十七个状态码分三组:调用方问题、环境问题、不确定问题;只有环境问题和部分冲突适合重试。
- linkd-v3 只定义六个原因码,
BOOK_NOT_FOUND、STOCK_CONFLICT、RATE_LIMITED、DEADLINE_EXCEEDED、UPSTREAM_UNAVAILABLE、TENANT_FORBIDDEN。 RpcError.toException把原因码打包进ErrorInfo,再由StatusProto写入 trailers;from在客户端还原,并在缺少 details 时回退到Status。DEADLINE_EXCEEDED默认不重试;ABORTED可以重试,但要重新读取版本后再试。- 网关映射要保留原因码,429/503/504 不能被压成 500。
错误能表达了,下一个问题是「这些错误码和字段由谁定义、怎么保证两边一致」。1.3 讲契约先行:.proto 怎么放、评审看什么、哪些改动一旦发布就不能回头。
阅读导航:上一节 1.1 从 RPC 语义到线格式 | 下一节 1.3 契约先行与演进边界
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。