《Java 服务端通信工程》9.3 错误与超时映射

错误映射不是一张状态码表,而是服务端如何构造 google.rpc.Status、客户端如何从 trailers 还原、网关如何写出 Retry-After 的完整往返。本节给出 GrpcErrors 工具、ErrorInfo 与 RetryInfo 的打包解析、gRPC 与 HTTP 双向映射,以及服务端 onError 和网关写响应的代码。

本节目标:让同一个故障在 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.CodeHTTP可重试说明
OK200否成功
INVALID_ARGUMENT400否参数错误,重发不会变
UNAUTHENTICATED401否凭证缺失或过期
PERMISSION_DENIED403否有凭证但无权限
NOT_FOUND404否资源不存在
ALREADY_EXISTS / ABORTED409视业务冲突;读后可重试
FAILED_PRECONDITION412否前置条件不满足
RESOURCE_EXHAUSTED429是限流或配额耗尽,按 Retry-After
CANCELLED499否客户端取消,通常不响应
UNIMPLEMENTED501否方法未实现
INTERNAL / UNKNOWN / DATA_LOSS500有限服务端缺陷
UNAVAILABLE503是下游不可用,退避重试
DEADLINE_EXCEEDED504是超时,预算允许时重试

几个边界要单独说。第一,ABORTED 映射到 409,不映射到 500;库存冲突是业务并发,不是服务端缺陷。第二,RESOURCE_EXHAUSTED 映射到 429 时必须带 Retry-After,否则客户端只能盲目重试。第三,DEADLINE_EXCEEDED 映射到 504,不是 503;503 表示「没连上或下游明确不可用」,504 表示「连上了但没在预算内完成」,两者的重试策略不同。第四,CANCELLED 通常不需要响应,客户端已经走了;499 只是 Nginx 日志口径,不是标准 HTTP 状态码。

反方向映射用于网关调用 libraryd:

HTTPgRPC说明
400INVALID_ARGUMENT请求格式或参数错误
401UNAUTHENTICATED凭证问题
403PERMISSION_DENIED权限问题
404NOT_FOUND资源不存在
409ABORTED冲突
412FAILED_PRECONDITION前置条件不满足
429RESOURCE_EXHAUSTED限流
500INTERNAL上游内部错误
501UNIMPLEMENTED未实现
502 / 503UNAVAILABLE网关或上游不可用
504DEADLINE_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_EXCEEDED504可以重试,但要扣总预算
下游连接被拒UNAVAILABLE503可以退避重试
客户端主动断开CANCELLED499不写响应,只记日志
网关自身线程池满自定义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-bin trailer;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

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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