本节目标:让同一个故障在 gRPC 调用方、网关和浏览器三处看到一致的错误码、原因码和重试建议,而不是每一层各翻译一次。
适用版本:JDK 25 + gRPC-Java 1.76.0(grpc-netty-shaded)+ protobuf-java-util 4.33.2。
承接的问题
9.1 的网关用一张 switch 把 Status.Code 翻译成 HTTP 状态码,9.2 的 BFF 把异常分成「整页失败」和「降级」两类。两处都在做映射,但还没有回答三个问题:原因码放在哪里、重试建议怎么传、超时和取消怎么区分。
gRPC 的 Status 只有 17 个码,粒度很粗。NOT_FOUND 可能是书不存在,也可能是租户不存在;ABORTED 可能是库存冲突,也可能是并发写冲突。客户端如果只看到 NOT_FOUND,只能显示「未找到」,无法给出「换一本」还是「重新登录」的建议。google.rpc.ErrorInfo 用 reason 字段补上这个粒度,RetryInfo 用结构化时间告诉客户端「多久之后可以重试」。
这一节把错误从服务端到客户端完整走一遍:
服务端业务异常
-> google.rpc.Status { code, message, details[ErrorInfo, RetryInfo] }
-> StatusProto.toStatusRuntimeException
-> gRPC trailers(grpc-status-details-bin)
-> 客户端 StatusProto.fromThrowable
-> 网关 HTTP 状态码 + Retry-After + JSON error body
核心机制一:gRPC 与 HTTP 的双向映射
映射表要同时满足两个方向:服务端返回 gRPC 错误时,网关知道该写哪个 HTTP 状态码;网关调用 libraryd 拿到 HTTP 错误时,知道该转成哪个 gRPC 码。两个方向不是简单反函数,因为 HTTP 状态码的语义更宽。
gRPC Status.Code | HTTP | 可重试 | 说明 |
|---|---|---|---|
OK | 200 | 否 | 成功 |
INVALID_ARGUMENT | 400 | 否 | 参数错误,重发不会变 |
UNAUTHENTICATED | 401 | 否 | 凭证缺失或过期 |
PERMISSION_DENIED | 403 | 否 | 有凭证但无权限 |
NOT_FOUND | 404 | 否 | 资源不存在 |
ALREADY_EXISTS / ABORTED | 409 | 视业务 | 冲突;读后可重试 |
FAILED_PRECONDITION | 412 | 否 | 前置条件不满足 |
RESOURCE_EXHAUSTED | 429 | 是 | 限流或配额耗尽,按 Retry-After |
CANCELLED | 499 | 否 | 客户端取消,通常不响应 |
UNIMPLEMENTED | 501 | 否 | 方法未实现 |
INTERNAL / UNKNOWN / DATA_LOSS | 500 | 有限 | 服务端缺陷 |
UNAVAILABLE | 503 | 是 | 下游不可用,退避重试 |
DEADLINE_EXCEEDED | 504 | 是 | 超时,预算允许时重试 |
几个边界要单独说。第一,ABORTED 映射到 409,不映射到 500;库存冲突是业务并发,不是服务端缺陷。第二,RESOURCE_EXHAUSTED 映射到 429 时必须带 Retry-After,否则客户端只能盲目重试。第三,DEADLINE_EXCEEDED 映射到 504,不是 503;503 表示「没连上或下游明确不可用」,504 表示「连上了但没在预算内完成」,两者的重试策略不同。第四,CANCELLED 通常不需要响应,客户端已经走了;499 只是 Nginx 日志口径,不是标准 HTTP 状态码。
反方向映射用于网关调用 libraryd:
| HTTP | gRPC | 说明 |
|---|---|---|
| 400 | INVALID_ARGUMENT | 请求格式或参数错误 |
| 401 | UNAUTHENTICATED | 凭证问题 |
| 403 | PERMISSION_DENIED | 权限问题 |
| 404 | NOT_FOUND | 资源不存在 |
| 409 | ABORTED | 冲突 |
| 412 | FAILED_PRECONDITION | 前置条件不满足 |
| 429 | RESOURCE_EXHAUSTED | 限流 |
| 500 | INTERNAL | 上游内部错误 |
| 501 | UNIMPLEMENTED | 未实现 |
| 502 / 503 | UNAVAILABLE | 网关或上游不可用 |
| 504 | DEADLINE_EXCEEDED | 上游超时 |
502 和 503 都映射到 UNAVAILABLE,但原因码可以区分:502 用 UPSTREAM_BAD_RESPONSE,503 用 UPSTREAM_UNAVAILABLE。原因码是给人和监控看的,状态码是给协议用的;两者不要混成一个字段。
核心机制二:把原因码和重试建议装进 details
google.rpc.Status 的结构是 code(int)、message(string)、details(重复的 Any)。code 用 Status.Code.value() 填,message 是给人看的简短描述,details 里放结构化信息。项目契约里的原因码放在 ErrorInfo.reason,域名放在 ErrorInfo.domain,附加上下文放在 ErrorInfo.metadata。
RetryInfo.retry_delay 是 google.protobuf.Duration,不是 Java 的 java.time.Duration。两者转换时要注意纳秒字段:java.time.Duration.getNano() 返回的是「秒以内的纳秒部分」,范围 0–999,999,999,正好对应 protobuf Duration 的 nanos。
package com.plume.linkd.v3.api;
import com.google.protobuf.Any;
import com.google.protobuf.InvalidProtocolBufferException;
import com.google.rpc.ErrorInfo;
import com.google.rpc.RetryInfo;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.grpc.protobuf.StatusProto;
import java.time.Duration;
import java.util.Map;
public final class GrpcErrors {
public static final String DOMAIN = "linkd.v3";
private GrpcErrors() {
}
public static StatusRuntimeException bookNotFound(String isbn) {
return error(Status.Code.NOT_FOUND, "BOOK_NOT_FOUND",
"book not found: " + isbn, null, Map.of("isbn", isbn));
}
public static StatusRuntimeException stockConflict(String isbn, int expected, int actual) {
return error(Status.Code.ABORTED, "STOCK_CONFLICT",
"stock version conflict", null,
Map.of("isbn", isbn,
"expected", Integer.toString(expected),
"actual", Integer.toString(actual)));
}
public static StatusRuntimeException rateLimited(Duration retryAfter) {
return error(Status.Code.RESOURCE_EXHAUSTED, "RATE_LIMITED",
"rate limit exceeded", retryAfter,
Map.of("limit", "200", "burst", "400"));
}
public static StatusRuntimeException deadlineExceeded(String detail) {
return error(Status.Code.DEADLINE_EXCEEDED, "DEADLINE_EXCEEDED",
detail, null, Map.of());
}
public static StatusRuntimeException upstreamUnavailable(String detail) {
return error(Status.Code.UNAVAILABLE, "UPSTREAM_UNAVAILABLE",
detail, null, Map.of());
}
public static StatusRuntimeException tenantForbidden(String tenant) {
return error(Status.Code.PERMISSION_DENIED, "TENANT_FORBIDDEN",
"tenant forbidden", null, Map.of("tenant", tenant));
}
private static StatusRuntimeException error(Status.Code code, String reason,
String message, Duration retryAfter,
Map<String, String> metadata) {
ErrorInfo.Builder info = ErrorInfo.newBuilder()
.setReason(reason)
.setDomain(DOMAIN)
.putAllMetadata(metadata);
com.google.rpc.Status.Builder status = com.google.rpc.Status.newBuilder()
.setCode(code.value())
.setMessage(message)
.addDetails(Any.pack(info.build()));
if (retryAfter != null) {
com.google.protobuf.Duration delay = com.google.protobuf.Duration.newBuilder()
.setSeconds(retryAfter.getSeconds())
.setNanos(retryAfter.getNano())
.build();
status.addDetails(Any.pack(RetryInfo.newBuilder()
.setRetryDelay(delay)
.build()));
}
return StatusProto.toStatusRuntimeException(status.build());
}
public static int httpStatus(Status.Code code) {
return switch (code) {
case OK -> 200;
case INVALID_ARGUMENT -> 400;
case UNAUTHENTICATED -> 401;
case PERMISSION_DENIED -> 403;
case NOT_FOUND -> 404;
case ALREADY_EXISTS, ABORTED -> 409;
case FAILED_PRECONDITION -> 412;
case RESOURCE_EXHAUSTED -> 429;
case CANCELLED -> 499;
case UNIMPLEMENTED -> 501;
case UNAVAILABLE -> 503;
case DEADLINE_EXCEEDED -> 504;
default -> 500;
};
}
public static Status.Code fromHttp(int status) {
return switch (status) {
case 400 -> Status.Code.INVALID_ARGUMENT;
case 401 -> Status.Code.UNAUTHENTICATED;
case 403 -> Status.Code.PERMISSION_DENIED;
case 404 -> Status.Code.NOT_FOUND;
case 409 -> Status.Code.ABORTED;
case 412 -> Status.Code.FAILED_PRECONDITION;
case 429 -> Status.Code.RESOURCE_EXHAUSTED;
case 500 -> Status.Code.INTERNAL;
case 501 -> Status.Code.UNIMPLEMENTED;
case 502, 503 -> Status.Code.UNAVAILABLE;
case 504 -> Status.Code.DEADLINE_EXCEEDED;
default -> status >= 500 ? Status.Code.UNAVAILABLE : Status.Code.UNKNOWN;
};
}
public static String reason(Throwable failure) {
ErrorInfo info = detail(failure, ErrorInfo.class);
return info == null ? "" : info.getReason();
}
public static long retryAfterSeconds(Throwable failure) {
RetryInfo info = detail(failure, RetryInfo.class);
if (info == null) {
return 0L;
}
com.google.protobuf.Duration delay = info.getRetryDelay();
long nanos = delay.getSeconds() * 1_000_000_000L + delay.getNanos();
return Math.max(1L, (nanos + 999_999_999L) / 1_000_000_000L);
}
private static <T extends com.google.protobuf.Message> T detail(
Throwable failure, Class<T> type) {
com.google.rpc.Status status = StatusProto.fromThrowable(failure);
if (status == null) {
return null;
}
for (Any any : status.getDetailsList()) {
if (!any.is(type)) {
continue;
}
try {
return any.unpack(type);
} catch (InvalidProtocolBufferException e) {
return null;
}
}
return null;
}
}
StatusProto.toStatusRuntimeException(status) 做两件事:把 com.google.rpc.Status 的 code 转成 io.grpc.Status,把整个 com.google.rpc.Status 序列化后放进 grpc-status-details-bin trailer。调用方拿到的 StatusRuntimeException.getStatus().getCode() 是 RESOURCE_EXHAUSTED,getTrailers() 里带着完整的 ErrorInfo 和 RetryInfo。这就是为什么不能只构造 Status.RESOURCE_EXHAUSTED.withDescription(...):描述字段是给人看的字符串,客户端无法可靠解析。
ErrorInfo.domain 用 linkd.v3,和 proto 的 package 对齐。metadata 里放的是稳定键,不要放堆栈、SQL、完整 URL;这些应该进日志。RetryInfo 只应该在真正可重试且服务端知道等待时间时附加;对 INVALID_ARGUMENT 加 RetryInfo 会误导客户端重试一个永远不会成功的请求。
核心机制三:trailers 的往返
gRPC 的错误详情走 trailer,不走 message。服务端把异常交给 StreamObserver.onError 时,gRPC 会检查异常是不是 StatusException / StatusRuntimeException,并从 Status.trailersFromThrowable 里取出 grpc-status-details-bin,合并到响应的 trailer 里。
public void getBook(GetBookRequest request, StreamObserver<Book> observer) {
try {
Book book = repository.find(request.getIsbn());
if (book == null) {
throw GrpcErrors.bookNotFound(request.getIsbn());
}
observer.onNext(book);
observer.onCompleted();
} catch (StatusRuntimeException e) {
observer.onError(e);
}
}
这里不要写 observer.onError(Status.fromThrowable(e).asRuntimeException())。Status.fromThrowable 只保留 code 和 description,asRuntimeException() 构造的新异常没有原来的 trailers,ErrorInfo 和 RetryInfo 全部丢失。客户端只能看到 NOT_FOUND,看不到 BOOK_NOT_FOUND,也看不到重试建议。
客户端还原用 StatusProto.fromThrowable:
try {
return stub.getBook(request);
} catch (StatusRuntimeException e) {
com.google.rpc.Status status = StatusProto.fromThrowable(e);
String reason = status == null ? "" : reasonOf(status);
if ("BOOK_NOT_FOUND".equals(reason)) {
throw new BookMissingException(request.getIsbn(), e);
}
throw e;
}
StatusProto.fromThrowable 对非 gRPC 异常返回 null,所以要先判空。Status.fromThrowable 对任意 Throwable 都返回一个 Status(通常是 UNKNOWN),两个方法的语义不同:前者还原结构化详情,后者只做粗粒度分类。
网关侧把 StatusRuntimeException 写成 HTTP 响应时,要从 trailers 里取 ErrorInfo.reason 和 RetryInfo,而不是从 getStatus().getDescription() 里猜。原因码进 JSON 的 error 字段,重试时间转成整数秒放进 Retry-After。
在 linkd-v3 里落地:服务端与网关两侧
服务端统一用 GrpcErrors 构造错误,不在业务代码里手写 Status.NOT_FOUND.asRuntimeException()。这样原因码、domain、metadata 的格式只有一处定义,新增错误类型时不会有人漏掉 ErrorInfo。
网关统一用 HttpErrorMapper 写错误响应:
package com.plume.linkd.v3.gateway;
import com.plume.linkd.v3.api.GrpcErrors;
import com.sun.net.httpserver.HttpExchange;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
public final class HttpErrorMapper {
public void write(HttpExchange exchange, Throwable failure) throws IOException {
StatusRuntimeException error = failure instanceof StatusRuntimeException sre
? sre
: Status.INTERNAL.withDescription("internal error").asRuntimeException();
Status.Code code = error.getStatus().getCode();
String reason = GrpcErrors.reason(error);
if (reason.isEmpty()) {
reason = code.name();
}
long retryAfter = GrpcErrors.retryAfterSeconds(error);
String body = "{\"error\":\"" + reason + "\",\"code\":\"" + code.name() + "\"}";
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
if (retryAfter > 0) {
exchange.getResponseHeaders().set("Retry-After", Long.toString(retryAfter));
}
exchange.sendResponseHeaders(GrpcErrors.httpStatus(code), bytes.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(bytes);
}
}
}
HttpErrorMapper 只暴露 error 和 code 两个字段,不把 message 原样回显。message 可能包含内部地址、SQL 片段或用户输入;对外只给稳定原因码,详细内容写日志,用 x-request-id 关联。x-request-id 应该从请求 metadata 里取,网关回显到响应头;这一段在 11.2 展开。
超时的映射要分清三种情况:
| 场景 | gRPC 码 | HTTP | 说明 |
|---|---|---|---|
| 下游在 300 ms 内没返回 | DEADLINE_EXCEEDED | 504 | 可以重试,但要扣总预算 |
| 下游连接被拒 | UNAVAILABLE | 503 | 可以退避重试 |
| 客户端主动断开 | CANCELLED | 499 | 不写响应,只记日志 |
| 网关自身线程池满 | 自定义 | 503 | 还没进 gRPC 调用 |
网关自身线程池满时没有 gRPC 码,直接写 503,原因码用 GATEWAY_OVERLOADED。不要为了统一而伪造一个 UNAVAILABLE 的 StatusRuntimeException,那会让日志里分不清「下游挂了」和「网关排队满了」。
原因码要稳定。BOOK_NOT_FOUND、STOCK_CONFLICT、RATE_LIMITED、DEADLINE_EXCEEDED、UPSTREAM_UNAVAILABLE、TENANT_FORBIDDEN 是项目契约里的六个值;新增原因码要改契约、加测试、通知客户端,不能随手写一个字符串。客户端按原因码做分支,原因码一变,分支就失效。
测试要覆盖往返,而不是只测状态码:
- 构造一个
GrpcErrors.stockConflict(...),用StatusProto.fromThrowable还原,断言reason是STOCK_CONFLICT、metadata里有isbn/expected/actual。 - 构造一个带
RetryInfo的RATE_LIMITED,断言retryAfterSeconds向上取整到 1 秒以上。 - 把异常交给
HttpErrorMapper,断言 HTTP 429、Retry-After头存在、body 里的error是RATE_LIMITED。 - 用一个普通
RuntimeException走 mapper,断言 500 且不泄漏异常消息。
常见错误与边界
错误一:HTTP 200 包着错误 body。 有些网关为了「统一响应格式」永远返回 200,让客户端解析 code 字段。这破坏了 HTTP 缓存、监控和重试策略;429 和 503 的语义必须体现在状态码上。
错误二:把 NOT_FOUND 映射成 500。 资源不存在是客户端问题,不是服务端缺陷。500 会触发告警和重试,把一次正常的「书不存在」放大成故障。
错误三:用 Status.fromThrowable(e).asRuntimeException() 重新抛。 这会丢掉 ErrorInfo 和 RetryInfo。要么原样抛 e,要么用 StatusProto.fromThrowable 重建完整的 com.google.rpc.Status。
错误四:429 不带 Retry-After。 客户端只能盲目重试,放大的流量会把刚恢复的服务再次打满。RetryInfo 和 Retry-After 要同时给,一个是 gRPC 客户端看的,一个是 HTTP 客户端看的。
错误五:把 ABORTED 映射成 500。 库存冲突是业务并发,客户端重新读取后可以重试;500 会让客户端放弃,也会污染服务端错误率。
错误六:把客户端取消映射成 504。 客户端已经断开,504 既写不回去,也会让监控误以为下游超时。取消只记日志,不写响应。
错误七:把异常消息直接回显。 内部地址、SQL、堆栈会泄漏到前端。对外只给稳定原因码,详细信息写日志。
边界:google.rpc.Status.code 是 int,不是枚举。 构造时用 Status.Code.value(),解析时用 Status.fromCodeValue(status.getCode());直接比较 int 容易和 HTTP 状态码混淆。
边界:StatusProto.fromThrowable 只认 gRPC 异常。 对普通 RuntimeException 返回 null,要先判空;不要把 null 当成「没有错误」。
边界:trailer 有大小限制。 grpc-status-details-bin 会占用 metadata 空间,ErrorInfo.metadata 不要塞大对象。gRPC 默认的 metadata 上限是 8 KiB 左右,超过会被截断或拒绝;详细上下文放日志,trailer 里只放定位所需的少量键。
边界:错误映射不修复故障。 把 UNAVAILABLE 映射成 503 不会让下游恢复;它只是让客户端知道「可以重试」。真正的恢复靠 7.2 的熔断、7.3 的隔离和容量规划。
小结
- gRPC 与 HTTP 的映射要双向:
ABORTED→409、RESOURCE_EXHAUSTED→429、UNAVAILABLE→503、DEADLINE_EXCEEDED→504、CANCELLED→499。 - 原因码放
ErrorInfo.reason,domain 用linkd.v3,上下文放metadata;RetryInfo只给真正可重试的错误。 StatusProto.toStatusRuntimeException把google.rpc.Status打包进grpc-status-details-bintrailer;StatusProto.fromThrowable还原。- 重新抛异常时不要用
Status.fromThrowable(e).asRuntimeException(),它会丢掉 details;原样抛或重建完整状态。 - 服务端统一用
GrpcErrors,网关统一用HttpErrorMapper;对外只暴露稳定原因码和状态码,详细信息写日志。 - 超时要区分 504(下游超时)、503(下游不可用)和 499(客户端取消);网关自身过载用 503 +
GATEWAY_OVERLOADED。
第 9 章把 HTTP 边界讲完了:网关、BFF、错误映射。下一章进入安全:10.1 从 TLS 握手和证书链开始,给 linkd-v3 的 9093 端口加上 TLS,再用 mTLS 确认调用方身份。
阅读导航:上一节 9.2 BFF 聚合与裁剪 | 下一节 10.1 TLS 与 mTLS
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。