适用场景

本文适用于 Node.js 20.3 及以上版本使用原生 fetch 调用内部 API、第三方服务或网关的 TypeScript 项目。示例使用该版本提供的 AbortSignal.any();更早版本可以用等价的信号组合函数替代。典型现象是:业务层已经返回“请求超时”,但下游仍持续收到请求;并发升高后连接数、内存和事件循环延迟继续上涨;开启重试后,下游故障期间流量反而成倍增加。

问题通常不在“有没有 Promise.race”,而在于超时是否真正传播到了网络请求,以及重试是否受同一个总预算约束。

现象描述

很多项目用下面的方式实现超时:

function delay(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function fetchWithFakeTimeout(url: string): Promise<Response> {
  return Promise.race([
    fetch(url),
    delay(2_000).then(() => {
      throw new Error("请求超时");
    }),
  ]);
}

两秒后调用方确实收到了异常,但 Promise.race 只决定哪个 Promise 的结果先被采用,不会取消另一个 Promise。此时 fetch(url) 仍可能继续解析 DNS、建立连接、等待响应或下载响应体。

如果外层收到超时后立即重试,旧请求与新请求会同时存在。一次慢下游可能逐步放大为连接堆积、重复写入和级联故障。

可能原因

  • 只用 Promise.race 返回超时,没有通过 AbortSignal 取消真实请求;
  • 每次重试都重新获得完整超时时间,三次重试把两秒预算扩张成六秒以上;
  • 不区分可重试错误,把参数错误、鉴权失败和业务冲突也加入重试;
  • 重试没有退避和随机抖动,大量实例在同一时刻再次冲击下游;
  • 调用方已经取消,但重试循环没有监听上游信号;
  • POST 等非幂等写操作没有幂等键,超时重试造成重复写入。

排查思路

1. 同时记录“业务结束”和“下游结束”

为一次调用生成稳定的 request_id,在入口返回、每次尝试开始、请求取消和下游响应时记录结构化日志。如果入口已返回数秒后仍出现相同 request_id 的下游完成日志,说明取消没有传播。

建议关注这些字段:

字段 含义 判断重点
request_id 一次业务调用的关联标识 是否出现入口结束后仍在执行的请求
attempt 当前尝试次数 是否发生无边界重试
elapsed_ms 从业务调用开始累计耗时 是否超过总预算
remaining_ms 发起本次尝试前的剩余预算 是否每次重试都被重置
error_name 异常类型 是否为 AbortError 或网络错误
status_code HTTP 状态码 是否只重试临时性失败

2. 检查连接与请求是否在超时后下降

在压测环境中让下游固定延迟 5 秒,而客户端预算设为 1 秒。停止流量后,活跃请求数应快速回落。如果业务错误数已经停止增长,但下游活跃请求或连接仍维持数秒,通常就是“返回超时但未取消请求”。

3. 检查重试放大倍数

统计入口请求数和下游请求数:

retry_amplification = downstream_request_total / incoming_request_total

如果下游故障时该值突然接近最大尝试次数,说明重试正在放大流量。还应按 status_codeerror_nameattempt 分组,确认 400、401、403 等永久性错误没有进入重试。

实现方案:共享总预算并传播取消

下面的实现满足四个约束:调用方取消立即停止;所有尝试共享总预算;只重试明确的临时错误;退避等待本身也可以被取消。

type FetchJsonOptions = {
  timeoutMs: number;
  maxAttempts?: number;
  signal?: AbortSignal;
};

class HttpStatusError extends Error {
  constructor(
    readonly status: number,
    readonly responseBody: string,
  ) {
    super(`下游返回异常状态: ${status}`);
    this.name = "HttpStatusError";
  }
}

function isRetryable(error: unknown): boolean {
  if (error instanceof HttpStatusError) {
    return error.status === 408 || error.status === 429 || error.status >= 500;
  }

  return error instanceof TypeError;
}

function abortableDelay(ms: number, signal: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    if (signal.aborted) {
      reject(signal.reason);
      return;
    }

    const onAbort = (): void => {
      clearTimeout(timer);
      reject(signal.reason);
    };
    const timer = setTimeout(() => {
      signal.removeEventListener("abort", onAbort);
      resolve();
    }, ms);
    signal.addEventListener("abort", onAbort, { once: true });
  });
}

export async function fetchJson<T>(
  url: string,
  options: FetchJsonOptions,
): Promise<T> {
  const maxAttempts = options.maxAttempts ?? 3;
  const deadlineSignal = AbortSignal.timeout(options.timeoutMs);
  const signal = options.signal
    ? AbortSignal.any([options.signal, deadlineSignal])
    : deadlineSignal;

  let lastError: unknown;

  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    if (signal.aborted) {
      throw signal.reason;
    }

    try {
      const response = await fetch(url, {
        method: "GET",
        headers: { Accept: "application/json" },
        signal,
      });

      if (!response.ok) {
        const body = (await response.text()).slice(0, 1_024);
        throw new HttpStatusError(response.status, body);
      }

      return (await response.json()) as T;
    } catch (error: unknown) {
      lastError = error;

      if (signal.aborted || !isRetryable(error) || attempt === maxAttempts) {
        throw error;
      }

      const baseDelayMs = 100 * 2 ** (attempt - 1);
      const jitterMs = Math.floor(Math.random() * 100);
      await abortableDelay(baseDelayMs + jitterMs, signal);
    }
  }

  throw lastError;
}

关键逻辑如下:

  • AbortSignal.timeout(timeoutMs) 从整个函数开始计时,因此重试不会刷新总预算;
  • AbortSignal.any() 把上游取消和本地超时合并,任一信号触发都会终止 fetch
  • abortableDelay() 让退避等待也接受取消,避免请求取消后仍睡眠;
  • 只把网络层 TypeError、408、429 和 5xx 视为候选临时错误;
  • 错误响应体最多读取 1 KiB,既释放响应体,也避免异常响应占用过多内存。

response.json() as T 只提供编译期类型提示,不会校验外部数据。生产代码应在边界使用 Zod、Valibot 或项目已有的运行时校验器验证响应结构。

调用示例

上游 HTTP 请求断开时,应把对应的取消信号传入下游调用:

type UserProfile = {
  id: string;
  displayName: string;
};

const controller = new AbortController();

request.on("close", () => {
  controller.abort(new Error("客户端连接已关闭"));
});

const profile = await fetchJson<UserProfile>(
  "https://profile.internal.example/users/42",
  {
    timeoutMs: 2_000,
    maxAttempts: 3,
    signal: controller.signal,
  },
);

不要把未经校验的用户输入直接拼进 URL。路径参数应使用 encodeURIComponent,查询参数应使用 URLURLSearchParams 构造。

测试超时与取消

可以使用 Node.js 内置测试运行器建立一个故意慢响应的服务,验证请求会在预算内终止:

import assert from "node:assert/strict";
import { createServer } from "node:http";
import { test } from "node:test";

test("达到总预算后取消真实请求", async (context) => {
  const server = createServer((_request, response) => {
    setTimeout(() => {
      response.writeHead(200, { "content-type": "application/json" });
      response.end('{"ok":true}');
    }, 1_000);
  });

  await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
  context.after(() => server.close());

  const address = server.address();
  assert(address && typeof address === "object");

  const startedAt = performance.now();

  await assert.rejects(
    fetchJson(`http://127.0.0.1:${address.port}`, {
      timeoutMs: 100,
      maxAttempts: 3,
    }),
    (error: unknown) =>
      error instanceof Error && error.name === "TimeoutError",
  );

  assert(performance.now() - startedAt < 500);
});

这项测试不只断言异常类型,还断言耗时没有随着重试次数线性增加。实际项目还应补充:上游主动取消、429 后成功、400 不重试、退避期间取消、响应 JSON 不合法等用例。

非幂等请求的额外约束

GET、HEAD 等只读请求通常更适合自动重试。订单创建、扣款、发券等写操作可能已经在下游成功,只是响应在网络中丢失。对这类请求必须同时满足:

  1. 业务协议支持幂等键,例如 Idempotency-Key
  2. 下游以“调用方 + 幂等键”建立唯一约束;
  3. 重试使用同一个幂等键,不能每次生成新值;
  4. 幂等结果保留时间覆盖客户端最大重试窗口。

没有幂等保障时,不要仅因为遇到超时就自动重试写请求。

预防措施

  • 把超时定义为端到端总预算,并为 DNS、连接、TLS、首字节等阶段补充可观测指标;
  • 复用 HTTP 客户端的底层连接池,不在每次请求前后人为销毁全局调度器;
  • 对重试次数、退避时间和可重试状态码建立统一策略;
  • 为下游调用设置并发上限,避免慢服务耗尽本服务资源;
  • 记录取消来源,但不要记录 Authorization、Cookie、令牌或完整敏感响应体;
  • 监控 attemptremaining_ms、取消数量和重试放大倍数;
  • 在故障演练中验证:上游取消后,下游活跃请求和连接数能够及时回落。

总结

Promise.race 能让调用方更早得到结果,却不能自动停止底层网络操作。可靠的超时治理必须让 AbortSignal 贯穿入口、重试循环、退避等待和真实 fetch 请求,并让所有尝试共享一个总预算。再配合有限重试、指数退避、随机抖动和写操作幂等,才能避免一次下游变慢被放大成连接堆积与重复请求。