【翻译】Fetch 需要错误码

1 阅读17分钟

Fetch 需要错误码

原文链接:www.jasnell.me/posts/fetch…

原文作者:James M Snell(文章作者,Cloudflare)

在我上一篇文章中,我梳理了 Fetch API 的一系列缺口——它们让 JavaScript 运行时无法表达现代 HTTP 的全部能力。其中一个缺口是带类型的流重置。我想专门深入这一点,因为目前有一项活跃的 TC39 提案可能有助于弥合它,而且时机也合适:该提案将在本月晚些时候的 TC39 全体会议上被审议是否推进阶段。

问题所在

fetch() 调用因网络层问题失败时,Fetch 规范要求返回的 promise 以 TypeError 拒绝。每一种网络失败(DNS 解析失败、TLS 握手错误、连接被拒绝、服务端流重置、重定向循环、客户端离线)都会产生同一种泛化的 TypeError.. 规范刻意销毁了有用的诊断信息。

下面这段代码展示了你今天在工程里会写的典型写法:

try {
  const response = await fetch(url, { method: 'POST', body });
} catch (e) {
  // e 多半是 TypeError。
  // 请求到达服务端了吗?被处理了吗?
  // 能否安全重试?无人知晓。
}

在浏览器中,这种不透明性是必要的安全特性。如果跨源 fetch() 失败会告诉你失败原因,你就可以探测目标的基础设施:主机能否解析?TLS 是否配置?8443 端口是否开放?Fetch 规范通过把每一次失败都折叠成同一种不透明信号来防止这一点。对浏览器威胁模型而言,隐藏异常细节通常是正确行为。

但 HTTP/2 和 HTTP/3 拥有超出「出了点问题」的错误信令。当服务端重置一条 HTTP/2 流时,它会发送携带 32 位错误码的 RST_STREAM 帧。构建在 QUIC 之上的 HTTP/3 有两种独立机制:RESET_STREAM(发送方突然停止发送)和 STOP_SENDING(接收方要求发送方停止)。两者都携带 HTTP/3 规范定义的应用层错误码。

这些码具有影响应用行为的具体语义。Fetch 规范把它们全部丢弃了。

值得关注的错误码

HTTP/2(RFC 9113)为 RST_STREAM 定义了十四个错误码。HTTP/3(RFC 9114)定义了十六个。其中多数是应用无需关心的协议 plumbing。但有少数携带直接影响应用下一步该怎么做的语义信息。

REFUSED_STREAM(HTTP/2)与 H3_REQUEST_REJECTED(HTTP/3)

服务端在告诉你:它根本没有处理该请求。RFC 9113 第 8.7 节 对含义写得很明确:

REFUSED_STREAM 错误码可包含在 RST_STREAM 帧中,表示流在尚未发生任何处理之前就被关闭。在已重置流上发送的任何请求都可以安全重试。

尚未被处理的请求并未失败;客户端可以自动重试它们,即使那些使用非幂等方法(non-idempotent methods)的请求也可以。

RFC 9114 第 4.1.1 节 对 HTTP/3 说了同样的话:

客户端可以把被服务端拒绝的请求当作从未发送过,从而允许稍后重试。

这是明确的可重试性保证。服务端在对客户端说:我从没见过你的 POST。再发一次。

然而在 fetch 中,REFUSED_STREAM 与 DNS 失败产生的是同一种 TypeError。可重试性保证被毁掉了。

其他有意义的错误码

  • CANCEL(HTTP/2)与 H3_REQUEST_CANCELLED(HTTP/3):流已不再需要,但服务端可能已部分处理该请求。这是「我改主意了」信号。不宜盲目重试。
  • NO_ERROR(HTTP/2)与 H3_NO_ERROR(HTTP/3):优雅关闭。没有出错。在服务端干净关闭时用于 GOAWAY 帧。
  • INTERNAL_ERROR(HTTP/2)与 H3_INTERNAL_ERROR(HTTP/3):服务端出了故障。不同于「被拒绝」(确定未处理),也不同于「被取消」(有意放弃)。

这四类(rejected、cancelled、no-error、internal-error)覆盖了最常见情形。在现行 Fetch API 中,它们无法区分。四类都会产生没有额外信息的 TypeError

QUIC 又增加了一个维度

HTTP/2 的 RST_STREAM 是单帧同时终止流的双向。QUIC——HTTP/3 之下的传输层——模型更细粒度。QUIC 中的流是双向的,每个方向可以独立终止:

  • RESET_STREAMRFC 9000 第 19.4 节):发送方突然终止其发送侧。当服务端在响应流上发送它时,意思是「我不再给你发这个响应了,原因如下」。它携带应用协议错误码。
  • STOP_SENDINGRFC 9000 第 19.5 节):接收方请求发送方停止发送。当服务端在请求流上发送它时,意思是「我不想要你上传的剩余部分」。同样携带应用协议错误码。

这是不同的失败模式。「我停止给你发响应」与「我不想要你请求体的剩余部分」有不同的恢复含义,尤其在 gRPC 双向流等全双工流式场景。但 Fetch 规范没有方向性流终止的概念。两者都产生同一种未加区分的 TypeError

Fetch 规范实际在做什么

直接看 Fetch 标准,其错误模型是一条刻意销毁信息的漏斗。

network error(网络错误)」是一种 type 为 "error"、status 为 0、头列表为空、无正文的响应。它不携带错误码、错误消息或 reason 字段。每一个网络错误在结构上都与其他网络错误完全相同。

规范只定义了一种变体:「aborted network error(中止的网络错误)」是设置了 aborted flag 的网络错误。这是规范所做的唯一区分。

当网络错误到达 fetch() API 边界时,它变成 TypeError 拒绝。具体来说,规范在 processResponse 步骤中说:

response 是网络错误,则以 TypeError 拒绝 p,并中止这些步骤。

TypeError 没有 .code、没有 .reason、没有协议元数据。它只是一个 TypeError。

规范恰好三次提到 RST_STREAM,三次都是关于发送,从未关于接收。规范在中止或重定向时说「若连接使用 HTTP/2,则传输 RST_STREAM 帧」。它从未规定当 serve* 向客户端发送 RST_STREAM 时该怎么办。它完全不引用 HTTP/3 的错误信令。GOAWAY 出现次数为零。

中止路径不同,值得注意。当通过 AbortSignal 取消 fetch 时,调用方可以附加一个能经受序列化并忠实传回的结构化 reason。中止路径是丰富的。网络错误路径是空的。

错误码提案

我一直在推动 TC39 中的一项提案,为 Error 对象增加标准化的 code 属性。该提案目前处于 Stage 1,将在下一次 TC39 全体会议上被审议是否推进到 Stage 2.7。

改动很小。它扩展 Error 构造函数的 options bag(与 ES2022 加入 cause 的是同一个)以接受 code

new TypeError("stream reset by server", {
  code: "ERR_HTTP_REQUEST_REJECTED"
})

code 属性定义在实例上(而非原型),接受任意值(不过字符串是主流约定),未提供时不存在,属性描述符语义与 cause 完全一致:不可枚举、可写、可配置。

生态系统已独立采用这一模式。我在 2017 年为 Node.js 错误加入了 .code 属性;如今已有 200 多个文档化的 ERR_* 码。Deno 在其 Node.js 兼容层中镜像它们。Bun 镜像并扩展到自身原生 API。axios、Firebase、Stripe、Prisma、AWS SDK 等库都在错误上设置 .code。这项提案只是在语言层面认可该属性,使其在构造函数中有明确定义语义,无需在构造后再 monkey-patch。

该属性会经受结构化克隆(structured cloning)与跨 realm 传输,这与 Fetch 规范通过 StructuredSerializeStructuredDeserialize 序列化中止 reason 的方式直接相关。

这如何帮助 Fetch

TypeError 能携带 .code,Fetch 规范就能通过现有错误表面传播协议错误信息,而无需引入新错误类型、在 Response 上增加新 API 表面,或改变 promise 解析模型。

处理被拒绝的 fetch 请求时,应用代码可以写成下面这样:

try {
  const response = await fetch(url, { method: 'POST', body });
} catch (e) {
  switch (e.code) {
    case 'ERR_HTTP_REQUEST_REJECTED':
      // 服务端保证:请求未被处理。可安全重试。
      return retry(url, { method: 'POST', body });

    case 'ERR_HTTP_REQUEST_CANCELLED':
      // 服务端可能已部分处理。不宜盲目重试。
      break;

    case 'ERR_HTTP_STREAM_RESET':
      // 泛化流重置。cause 可能含协议细节。
      break;

    default:
      // 无 code,或无法识别的 code。行为与今天相同。
      throw e;
  }
}

不检查 .code 的代码完全不受影响。TypeError 还是同一个 TypeError。promise 拒绝还是同一种拒绝。唯一区别是错误现在可选地携带失败条件的机器可读标识符。

cause 属性(ES2022 已有)可为需要协议层细节的应用承载该信息:

new TypeError("stream reset by server", {
  code: "ERR_HTTP_REQUEST_REJECTED",
  cause: {
    protocol: "h2",
    errorCode: 0x07,
    errorName: "REFUSED_STREAM"
  }
})

诚然,对于 cause 是否应用于这类元数据存在分歧。我自己也拿不准。有人希望 cause 永远只是另一个 Error 对象,而不是通用元数据袋。要点在于:若实现需要,仍有选项承载协议层细节。

错误码分类体系

抽象错误码需要覆盖 HTTP/2 与 HTTP/3 能表达的各种失败条件,同时保持与协议版本无关。我们需要定义这套分类体系。

流级错误

映射到 RST_STREAM(HTTP/2)以及 RESET_STREAM / STOP_SENDING(HTTP/3)帧。

CodeMeaningHTTP/2 SourceHTTP/3 Source
ERR_HTTP_REQUEST_REJECTEDServer did not process the request. Safe to retry.REFUSED_STREAM (0x07)H3_REQUEST_REJECTED (0x010b)
ERR_HTTP_REQUEST_CANCELLEDRequest or response was intentionally cancelled. May have been partially processed.CANCEL (0x08)H3_REQUEST_CANCELLED (0x010c)
ERR_HTTP_INTERNAL_ERRORInternal error in the remote HTTP stack.INTERNAL_ERROR (0x02)H3_INTERNAL_ERROR (0x0102)
ERR_HTTP_STREAM_RESETGeneric stream reset. Used when the specific code doesn't map to a more specific abstract code, or is unrecognized.Any other RST_STREAM codeAny other application error code

这四类覆盖最常见情形。ERR_HTTP_REQUEST_REJECTED 是价值最高的一项,因为它解锁了对非幂等请求的安全自动重试。

连接级错误

映射到 GOAWAY 帧以及影响在途请求的连接级失败:

CodeMeaningHTTP/2 SourceHTTP/3 Source
ERR_HTTP_GOAWAYServer is shutting down gracefully. Requests on streams above the last-stream-id were never processed and are safe to retry.GOAWAY frameGOAWAY frame
ERR_HTTP_PROTOCOL_ERRORProtocol-level violation. The connection or stream was terminated due to a framing or protocol error.PROTOCOL_ERROR (0x01)H3_GENERAL_PROTOCOL_ERROR (0x0101)
ERR_HTTP_CONNECT_ERRORA CONNECT tunnel was reset or abnormally closed.CONNECT_ERROR (0x0a)H3_CONNECT_ERROR (0x010f)

ERR_HTTP_GOAWAY 很有意思,因为对 last-stream-id 之上的请求,它携带与 ERR_HTTP_REQUEST_REJECTED 相同的可重试语义,但机制是连接作用域而非流作用域。

注意 last-stream-id 在这里并未暴露……我们还需要想清楚如何表达它。

方向性重置码

针对 HTTP/3 over QUIC,流的收发方向可独立终止:

CodeMeaningSource
ERR_HTTP_RESPONSE_RESETServer stopped sending the response body.RESET_STREAM on the response direction
ERR_HTTP_REQUEST_BODY_REJECTEDServer doesn't want the rest of the request body.STOP_SENDING on the request direction

这对全双工流式有意义。「服务端不再想要我的上传」与「服务端停止发送其响应」是不同情境,恢复策略也不同。对只有单一 RST_STREAM 帧、同时终止双向的 HTTP/2,这些不适用。

传输层与连接前错误码

这些位于 HTTP 层之下,在浏览器中可能引发跨源信息泄露担忧:

CodeMeaningSource
ERR_HTTP_DNS_RESOLUTIONDNS resolution failed.DNS lookup failure
ERR_HTTP_TLS_ERRORTLS handshake failed.TLS error
ERR_HTTP_CONNECTION_REFUSEDConnection was refused.TCP/QUIC connection failure
ERR_HTTP_CONNECTION_RESETConnection was reset.TCP reset, QUIC connection close
ERR_HTTP_TIMEOUTRequest timed out.Various timeout conditions

在浏览器中,这些码大概不应向跨源请求暴露,因为它们会泄露目标网络拓扑信息。主机能否解析、TLS 是否配置、端口是否开放——这些都是现行不透明性所阻止的探测。对同源请求,担忧不那么尖锐。对没有 CORS 威胁模型的服务端运行时,没有理由隐瞒它们。

Fetch 规范可以定义这些码并标记为「在跨源上下文中不暴露」,或限制为 opt-in 模式。或者由 WinterTC 扩展规范定义完整集合,而 Fetch 规范只限于流级错误码——它们可安全暴露,因为只在连接建立之后、且服务端自愿向客户端传达类型化信息时才会出现。

广度论证

上述分类体系并非要把每一个可能的协议错误码都暴露给 JavaScript。HTTP/2 与 HTTP/3 的大多数错误码是协议 plumbing:FLOW_CONTROL_ERRORFRAME_SIZE_ERRORCOMPRESSION_ERRORSETTINGS_TIMEOUT。它们对协议实现者重要,对应用开发者不重要。抽象错误码集合聚焦于对应用行为有语义含义的码:可重试吗?被取消了吗?被服务端拒绝了吗?是方向性半关闭吗?

若应用需要原始协议错误码(用于日志、协议感知代理、调试),我们仍须想清楚如何暴露。cause 属性是一种选项,但可能还有别的。

分类体系应从小处起步再增长。仅四个流级错误码就覆盖了最高价值用例。

对 fetch 的改动

要对 Fetch 规范做的改动,大致可分为以下几个类别:

  • 网络错误概念本身,
  • 创建网络错误的内部算法,以及
  • 把它们转换为 TypeError 的 API 边界。

网络错误概念

网络错误 定义目前规定一种 type 为 "error"、status 为 0、头为空、body 为 null 的响应。它不携带元数据。

改动:为网络错误概念增加可选的「network error code(网络错误码)」字段。大致如下:

网络错误是一种 type 为 "error"、status 为 0、status message 为空字节序列、header list 为空、body 为 null、body info 为新的 response body info,且 network error code 为 null 的响应。

在此基础上,还可以补充如下定义:

带 code 的网络错误(network error with code)是其 network error code 被设为给定值的网络错误。

网络错误码不会直接暴露给 JavaScript。它是内部字段,会转换为 TypeError 上的 .code 属性。

创建网络错误的算法

Fetch 规范在许多地方创建网络错误。大多数不需要错误码。DNS 失败、连接被拒绝、TLS 错误:都在「获取连接」层级,在浏览器上下文中应保持不透明。

在「HTTP-network fetch」中,收到头之后、正在流式传输数据时,算法目前有处理中止/终止的步骤,以「返回网络错误」或「用 TypeError 使流出错」结束。这些步骤需要扩展,以从底层 HTTP/2 或 HTTP/3 实现检测协议错误码并贯穿传递。

例如,现行规范说(概括正文流循环附近的步骤):

若已中止,则:若连接使用 HTTP/2,传输 RST_STREAM 帧。返回相应的网络错误。

现行规范步骤需要调整,表述上大致应类似于下面这样:

若流被服务端重置,令 errorCode 为流错误码。令 networkErrorCode 为将 errorCode 映射到抽象错误码的结果。返回带 code networkErrorCode 的网络错误。

同样地,正文流式传输相关的步骤目前是下面这样表述的:

否则,若 stream 可读,用 TypeError 使流出错。

此外还必须把「收到流重置」这一情形纳入算法步骤的考虑范围:

否则,若 stream 可读,用 code 为网络错误之 network error code 的 TypeError 使流出错。

terminate 算法

fetch controller 的「terminate」算法目前只是把状态设为 "terminated",没有额外信息。对比「abort」算法:它接受一个 error,序列化并存储为「serialized abort reason」。

terminate 路径需要类似的槽位。当流被服务端重置(而非被调用方取消)时,重置码应存储在 fetch controller 上,以便贯穿到最终到达用户代码的 TypeError

一种做法:为 fetch controller 增加「termination reason」字段,与现有「serialized abort reason」并行。因流重置而 terminate 时存储错误码。当规范稍后为非中止(non-aborted)的 fetch 构造 TypeError 时使用该码:

否则,若 stream 可读,用 code 为 fetchParams 之 controller 的 termination reason 之 code 的 TypeError 使流出错。

fetch() API 边界

fetch() 函数的 processResponse 步骤目前说:

response 是网络错误,则以 TypeError 拒绝 p,并中止这些步骤。

在引入错误码机制之后,同一处的规范表述将相应地变为:

response 是网络错误,则以 TypeError 拒绝 p。若 response 的 network error code 非 null,则该 TypeError 的 code 为 response 的 network error code。中止这些步骤。

安全作用域

显然,并非所有错误码都应在所有上下文中暴露。规范需要一步检查给定码是否可安全暴露。流级错误码(ERR_HTTP_REQUEST_REJECTEDERR_HTTP_REQUEST_CANCELLED 等)是安全的,因为它们代表服务端在连接建立后选择传达的信息。连接前错误码(ERR_HTTP_DNS_RESOLUTIONERR_HTTP_TLS_ERROR)会泄露基础设施细节,应被抹除:

response 的 network error code 是连接前错误码且请求为跨源,则将 TypeError 的 code 设为 null。

不实现 CORS 的服务端运行时可以完全跳过此检查,就像它们今天已跳过 CORS 强制执行一样。

正文流情形

错误码不仅在有用的 fetch() promise 拒绝层级。错误也可能在响应头已到达、promise 已 resolve 之后发生。此时错误出现在响应正文的 ReadableStream 上。

规范目前在正文传输期间连接被终止(非中止)时用裸 TypeError 使正文流出错。有了错误码,该 TypeError 会携带相同的 .code

const response = await fetch(url);  // 正常 resolve,头已收到

const reader = response.body.getReader();
try {
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    process(value);
  }
} catch (e) {
  if (e.code === 'ERR_HTTP_REQUEST_CANCELLED') {
    // 服务端在流传输中途取消了响应。
  }
}

这对流式用例很重要——你想知道流为何过早结束。「服务端取消」「服务端错误」「连接丢失」都是不同情境。

时机

错误码提案将在本月晚些时候的 TC39 全体会议上被审议是否推进。作为 Stage 1 提案,委员会已同意问题值得解决。现在的疑问是具体方案(在 Error 构造函数 options bag 上增加 .code 属性)是否正确,以及是否足够成熟可以推进。

Fetch 错误报告缺口是一个具体案例(也是我最初提出 .code 属性的动机之一)。HTTP/2 与 HTTP/3 拥有丰富的类型化错误信令,实现已在协议层收到它们。

code 属性是把该信息贯穿到应用代码的最小、向后兼容机制。

若提案推进,Fetch 规范可以更新以使用它。错误码分类体系可由 Fetch 规范自身定义,或由单独扩展规范定义(可能由 WinterTC 负责)。