《Java 服务端通信工程》1.2 错误模型与状态码

把 gRPC 的 Status 码、trailers 与 google.rpc.ErrorInfo 分成三层,给出 linkd-v3 六个错误原因码到状态码的映射和可重试判定,并用 RpcError 的 toException/from 实现服务端抛错、客户端还原的完整闭环。

承接的问题

1.1 把一次调用拆成了五个语义要素,其中「错误」只说了落在 trailers。真实系统里,错误表达不清的代价比超时更大:超时可以重试,错误判错会把「书不存在」当成「服务挂了」,触发一轮没有意义的重试;或者把「库存冲突」当成永久失败,丢掉本可以成功的请求。

这一节解决三个问题:错误码用哪一层表达、哪些错误可以重试、服务端和客户端怎么用同一套类型把它传过去。答案不是「定义一个 code 字段」,而是三层结构:Status.Code 给粗分类,ErrorInfo.reason 给稳定原因,message 给人看。

Status 的十七个码

gRPC 的 Status.Code 是固定枚举,不能扩展。它的人数不多,但每一个都有明确语义:

码数值含义典型场景
OK0成功正常返回
CANCELLED1调用方主动取消客户端超时、用户离开页面
UNKNOWN2未知错误未捕获异常、无法归类
INVALID_ARGUMENT3参数不合法ISBN 格式错误、分页大小为负
DEADLINE_EXCEEDED4超过 deadline下游慢、排队过久
NOT_FOUND5资源不存在书不存在、租户不存在
ALREADY_EXISTS6资源已存在重复创建、唯一键冲突
PERMISSION_DENIED7无权限token 无效、租户越权
RESOURCE_EXHAUSTED8资源耗尽限流、配额、消息过大
FAILED_PRECONDITION9前置条件不满足状态机不允许当前操作
ABORTED10并发冲突乐观锁失败、库存版本冲突
OUT_OF_RANGE11越界分页偏移超出范围
UNIMPLEMENTED12方法未实现版本不匹配、方法名写错
INTERNAL13内部错误不该发生的 bug、序列化失败
UNAVAILABLE14服务不可用连接失败、下游宕机、过载
DATA_LOSS15数据丢失或损坏解码失败、存储损坏
UNAUTHENTICATED16未认证缺少 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 的六个原因码

项目只定义六个原因码,覆盖当前业务。新增原因码要走契约评审,不能随手加字符串:

reasonStatus.Code可重试什么时候用
BOOK_NOT_FOUNDNOT_FOUND否ISBN 在库中不存在
STOCK_CONFLICTABORTED是库存版本冲突,调用方可重读后重试
RATE_LIMITEDRESOURCE_EXHAUSTED是触发限流,按 RetryInfo 退避
DEADLINE_EXCEEDEDDEADLINE_EXCEEDED否本次调用预算耗尽,重试只会更慢
UPSTREAM_UNAVAILABLEUNAVAILABLE是下游不可达、连接被拒
TENANT_FORBIDDENPERMISSION_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 codeHTTP 状态响应体
INVALID_ARGUMENT400{"code":"INVALID_ARGUMENT","reason":"...","message":"..."}
UNAUTHENTICATED401同上
PERMISSION_DENIED403同上
NOT_FOUND404同上
ALREADY_EXISTS / ABORTED409同上
FAILED_PRECONDITION412同上
RESOURCE_EXHAUSTED429附带 Retry-After
DEADLINE_EXCEEDED504同上
UNAVAILABLE503同上
其他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 契约先行与演进边界

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收