01 connect-timeout 工作原理、API 与配置

1. 它解决的是什么超时

一次 HTTP 调用可能同时存在很多超时:

客户端发送请求头       → server.headersTimeout
客户端发送完整请求体   → server.requestTimeout
Express 处理业务       → connect-timeout
响应后的连接复用等待   → server.keepAliveTimeout
前端愿意等待多久       → Axios timeout

connect-timeout 关注的是:

请求进入该中间件以后,在规定时间内是否开始发送响应。

它不是 Express 5 内置中间件,而是 Express 官方资源页收录的第三方中间件。

2. 安装与 TypeScript 类型

npm install connect-timeout
npm install --save-dev @types/connect-timeout

connect-timeout 本身不携带 TypeScript 声明,需要社区维护的 @types/connect-timeout

当前项目采用 esModuleInterop 时通常可以这样导入:

import timeout from "connect-timeout";

如果项目没有开启相应的模块互操作配置,应根据 tsconfig.json 使用与当前模块配置匹配的导入方式。不要只为一个示例贸然切换整个工程到 ESM。

3. 最基本的路由配置

import express, {
  type NextFunction,
  type Request,
  type Response,
} from "express";
import timeout from "connect-timeout";

const app = express();

app.get(
  "/api/report",
  timeout("10s"),
  async (req: Request, res: Response) => {
    const report = await createReport();

    if (req.timedout) {
      return;
    }

    res.json(report);
  },
);

时间可以传毫秒数:

timeout(10_000);

也可以传给 ms 包能够识别的字符串:

timeout("500ms");
timeout("10s");
timeout("2m");

教程中更推荐显式字符串或带单位含义的常量,避免误把秒当成毫秒。

4. 底层工作原理

其核心逻辑可以简化为:

function timeoutMiddleware(delay: number) {
  return (req: Request, res: Response, next: NextFunction) => {
    const timer = setTimeout(() => {
      req.timedout = true;
      req.emit("timeout", delay);
    }, delay);

    req.timedout = false;
    req.clearTimeout = () => clearTimeout(timer);

    // 响应开始或结束后清除 timer。
    // 真实源码使用 on-headers 和 on-finished。

    next();
  };
}

默认 respond: true 时,它还会监听 timeout 事件,并调用类似:

next(timeoutError);

这个错误具有:

error.status  = 503
error.timeout = 本次超时毫秒数

4.1 为什么它停止不了后续工作

中间件调用 next() 后,控制权已经交给后续中间件和 Route:

connect-timeout 启动 timer
        ↓
调用 next()
        ↓
Route 开始数据库查询
        ↓
timer 到期并传递 503 错误
        ↓
数据库查询仍由数据库驱动继续执行

JavaScript 没有一种通用机制,可以从外部强制取消任意 Promise。真正的操作是否停止,取决于数据库驱动、HTTP 客户端、文件 API 或业务函数是否支持取消。

4.2 它不是精确的硬实时计时器

底层使用 setTimeout()。如果事件循环被同步 CPU 任务阻塞:

while (true) {
  // 阻塞事件循环
}

超时回调本身也没有机会运行。因此它无法解决同步死循环或长时间同步计算。CPU 密集任务应放到 Worker Thread、独立进程或任务队列。

4.3 响应头开始发送后,计时器会被清理

源码使用 on-headerson-finished 清理计时器。这意味着它主要约束“开始响应前的等待时间”,不是完整响应体传输的绝对总时间。

例如:

app.get("/stream", timeout("5s"), (req, res) => {
  res.writeHead(200, {
    "Content-Type": "text/plain; charset=utf-8",
  });

  // 响应头已经开始发送,connect-timeout 的 timer 会被清理。
  // 后续流式传输可能持续很久。
});

SSE、文件下载和流式响应应使用各自的连接、空闲、心跳和生命周期策略,不能依赖 connect-timeout 控制整个传输时长。

5. 配置项 respond

默认配置:

timeout("10s", {
  respond: true,
});

超时后中间件调用 next(error),由 Express 错误处理链返回响应。

设置为 false

timeout("10s", {
  respond: false,
});

这时中间件仍会:

但不会自动把超时错误传给 next()。你必须自己监听事件并决定如何响应:

app.get(
  "/api/task",
  timeout("10s", { respond: false }),
  (req, res, next) => {
    req.once("timeout", () => {
      if (!res.headersSent) {
        res.status(503).json({
          code: "REQUEST_TIMEOUT",
          message: "请求处理超时",
        });
      }
    });

    next();
  },
  taskHandler,
);

一般情况下保留 respond: true,通过统一错误处理中间件格式化响应,更容易维护。

6. req.timedoutreq.clearTimeout()

req.timedout

if (req.timedout) {
  return;
}

它只能告诉代码 timer 是否已经触发,不能证明底层任务已经取消。

req.clearTimeout()

req.clearTimeout();

它会清除本次中间件设置的 timer,之后该 timer 不再触发。它不是“暂停”,也不能恢复剩余时间。

适合明确切换生命周期的场景,但不应随意调用,否则请求可能永久失去应用层 deadline。

7. haltOnTimedout 的作用

官方示例使用:

function haltOnTimedout(
  req: Request,
  _res: Response,
  next: NextFunction,
): void {
  if (!req.timedout) {
    next();
  }
}

它只阻止尚未开始的下一个中间件继续执行:

app.use(timeout("5s"));

app.use(express.json());
app.use(haltOnTimedout);

app.use(loadCurrentUser);
app.use(haltOnTimedout);

app.use(apiRouter);

它无法打断已经进入的异步函数:

await slowDatabaseQuery();

所以 haltOnTimedout 是中间件流程防护,不是资源取消机制。

8. 是否应该全局配置

最简单的全局配置是:

app.use(timeout("10s"));

但官方明确不推荐在没有防护措施时把它作为顶层中间件,因为超时后后续流程可能继续运行。

8.1 可以全局使用的条件

全局使用通常需要同时满足:

8.2 不适合直接全局使用的场景

9. 更好的全局策略:先决定预算,再启动一次 timer

不要先启动全局 10 秒 timer,再无条件叠加一个 60 秒 timer。两个 timer 是独立的,短的那个仍可能先触发。

可以根据路由分类选择一个预先创建的中间件:

const normalTimeout = timeout("10s");
const reportTimeout = timeout("60s");

function timeoutPolicy(
  req: Request,
  res: Response,
  next: NextFunction,
): void {
  if (req.path.startsWith("/api/reports/")) {
    reportTimeout(req, res, next);
    return;
  }

  normalTimeout(req, res, next);
}

app.use(timeoutPolicy);

但随着规则增加,路径判断会变得脆弱。更推荐按 Router 划分:

const normalApiRouter = express.Router();
const reportRouter = express.Router();

normalApiRouter.use(timeout("10s"));
normalApiRouter.use(haltOnTimedout);

reportRouter.use(timeout("60s"));
reportRouter.use(haltOnTimedout);

app.use("/api/reports", reportRouter);
app.use("/api", normalApiRouter);

每个请求只经过一个超时中间件,边界最清楚。

10. 已经全局配置后,个别路由如何延长

下面的写法不能可靠地覆盖全局 timeout:

app.use(timeout("10s"));

app.get(
  "/api/reports/monthly",
  timeout("60s"),
  reportHandler,
);

因为全局 10 秒 timer 仍然存在,60 秒 timer 不会自动替换它。

方案一:推荐——全局层排除特殊 Router

const defaultTimeout = timeout("10s");

app.use("/api/reports", timeout("60s"), reportRouter);

app.use("/api", defaultTimeout, normalApiRouter);

要保证特殊 Router 被完整处理,不要在正常响应后继续 next() 进入普通 Router。

方案二:清除旧 timer 后立即安装新 timer

如果工程结构暂时无法调整,可以显式替换:

const longTimeout = timeout("60s");

function replaceWithLongTimeout(
  req: Request,
  res: Response,
  next: NextFunction,
): void {
  req.clearTimeout();
  longTimeout(req, res, next);
}

app.use(timeout("10s"));

app.get(
  "/api/reports/monthly",
  replaceWithLongTimeout,
  reportHandler,
);

注意:

11. 练习题

  1. 写出 headersTimeoutrequestTimeoutconnect-timeout 和 Axios timeout 各自负责的阶段。
  2. 为普通 API 和报表 API 创建两个 Router,分别设置 8 秒和 45 秒超时。
  3. 证明“全局 5 秒 + Route 30 秒”不能自动覆盖:编写一个耗时 10 秒的测试 Route。
  4. 使用 respond: false 自行监听 timeout 事件并返回统一 JSON。
  5. 编写 haltOnTimedout,并解释它为什么不能取消已经开始的 Promise。
  6. 创建一个提前发送响应头、随后缓慢发送响应体的 Route,观察 connect-timeout 的行为。
  7. 列出当前项目中不适合使用统一全局 deadline 的接口类型。

官方参考