03 生产环境搭配、错误处理与测试

1. 推荐的超时时间层级

一次生产请求可能经过:

Axios / 浏览器
  → CDN / Load Balancer
  → Nginx
  → Express connect-timeout
  → 下游 HTTP / Redis / MySQL

通常让内部依赖先超时,外层留出返回结构化错误的时间:

下游依赖 timeout
        <
Express Route deadline
        <
Nginx / 网关 timeout
        <
Axios timeout

例如:

MySQL 查询限制      4 秒
下游 HTTP timeout   5 秒
Express deadline    8 秒
Nginx               9 秒
Axios               10 秒

这只是示例。真实值应根据接口 SLO、历史延迟、依赖能力和用户体验确定。

2. 一个推荐的 Express 5 组合

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

const app = express();

app.use(requestIdMiddleware);
app.use(accessLogMiddleware);

const apiRouter = express.Router();

apiRouter.use(timeout("8s"));
apiRouter.use(attachCancellation);

apiRouter.use(express.json({ limit: "1mb" }));
apiRouter.use(haltOnTimedout);

apiRouter.use(authenticate);
apiRouter.use(haltOnTimedout);

apiRouter.use(routes);

app.use("/api", apiRouter);

app.use(notFoundHandler);
app.use(errorHandler);

顺序的含义:

  1. request ID 和日志覆盖整个调用;
  2. deadline 尽早启动;
  3. cancellation middleware 把 timeout 和连接断开转换为 AbortSignal
  4. 解析请求体和认证后检查是否已超时;
  5. Route 内部仍要取消真正的慢资源;
  6. 最终由统一错误处理中间件返回 JSON。

如果请求体很大,Node.js requestTimeout、Nginx 请求体限制和上传策略仍需单独配置。connect-timeout 不能替代这些 HTTP 接收阶段限制。

3. 统一超时错误响应

interface TimeoutLikeError extends Error {
  status?: number;
  timeout?: number;
}

const errorHandler: ErrorRequestHandler = (
  error: TimeoutLikeError,
  req,
  res,
  next,
) => {
  if (res.headersSent) {
    next(error);
    return;
  }

  if (error.timeout !== undefined) {
    res.status(503).json({
      code: "REQUEST_TIMEOUT",
      message: "服务器未能在规定时间内完成请求",
      requestId: res.locals.requestId,
    });
    return;
  }

  res.status(error.status ?? 500).json({
    code: "INTERNAL_SERVER_ERROR",
    message: "服务器内部错误",
    requestId: res.locals.requestId,
  });
};

官方中间件默认产生 503 Service Unavailable

504 Gateway Timeout 从 HTTP 语义上通常表示网关或代理等待上游服务超时。如果 Express 自己充当某个下游服务的网关,可以根据团队规范映射为 504;但不要在日志中丢失真正的超时来源。

4. 防止二次响应

慢操作返回时,timeout error 可能已经发送响应。Route 必须停止:

const result = await slowOperation();

if (
  req.timedout ||
  req.execution?.controller.signal.aborted ||
  res.headersSent ||
  res.destroyed
) {
  return;
}

res.json(result);

这些检查各自含义不同:

检查只能防止二次响应,不能取代底层操作取消。

5. 日志应该记录什么

超时日志建议使用结构化字段:

{
  "event": "request_timeout",
  "requestId": "req-123",
  "method": "GET",
  "route": "/api/reports/monthly",
  "timeoutMs": 8000,
  "elapsedMs": 8012,
  "userId": 42,
  "phase": "mysql_query",
  "clientDisconnected": false
}

注意不要把以下内容直接写入日志:

建议区分:

deadline_exceeded     服务端应用期限到达
client_disconnected   客户端或代理提前关闭连接
dependency_timeout    MySQL/HTTP/Redis 等依赖超时
queue_timeout         等待连接池或队列资源超时

如果全部记录为 REQUEST_TIMEOUT,后期很难找到真正瓶颈。

6. 监控指标

不要只看平均响应时间。平均值正常时,P99 仍可能严重超时。

7. 用 Supertest 测试超时响应

import express from "express";
import request from "supertest";
import timeout from "connect-timeout";

const app = express();

app.get(
  "/slow",
  timeout("50ms"),
  async (req, res) => {
    await new Promise<void>((resolve) => {
      setTimeout(resolve, 100);
    });

    if (!req.timedout) {
      res.json({ success: true });
    }
  },
);

app.use(errorHandler);

await request(app)
  .get("/slow")
  .expect(503)
  .expect(({ body }) => {
    if (body.code !== "REQUEST_TIMEOUT") {
      throw new Error("错误码不正确");
    }
  });

测试环境使用很短的 timeout 可以提高速度,但要留有合理余量,避免 CI 机器负载造成偶发失败。

8. 测试“响应后工作是否仍继续”

只断言 503 不够,还要验证资源被取消:

let completed = false;
let cancelled = false;

async function cancellableWork(
  signal: AbortSignal,
): Promise<void> {
  await new Promise<void>((resolve, reject) => {
    const timer = setTimeout(() => {
      completed = true;
      resolve();
    }, 200);

    signal.addEventListener(
      "abort",
      () => {
        clearTimeout(timer);
        cancelled = true;
        reject(signal.reason);
      },
      { once: true },
    );
  });
}

测试应断言:

HTTP 返回超时
cancelled === true
completed === false
没有未处理的 Promise rejection
没有残留数据库事务或连接

9. 测试客户端主动 abort

客户端断开测试需要验证:

这类测试可以使用原生 node:http 创建请求,再主动调用 request.destroy(),因为部分高级测试库会隐藏底层断开行为。

10. 与 Nginx 配合

示例:

location /api/ {
    proxy_connect_timeout 3s;
    proxy_send_timeout 10s;
    proxy_read_timeout 10s;
    proxy_pass http://node_backend;
}

proxy_read_timeout 更接近两次读取上游数据之间允许的空闲时间,并不总是“整个请求绝对只能执行 10 秒”。实际行为要结合响应是否持续输出数据理解。

如果 Nginx 先超时并关闭上游连接,Express 应通过连接 close 取消可取消任务。否则 Node.js 仍可能继续处理一个已无人等待的请求。

11. 不要用延长 timeout 掩盖架构问题

当接口经常需要几分钟,应优先判断它是否应该改为后台任务:

POST /api/reports
→ 202 Accepted
{
  "jobId": "report-123"
}

查询进度:

GET /api/report-jobs/report-123

结果:

{
  "status": "completed",
  "downloadUrl": "/api/reports/report-123/download"
}

适合异步化的任务:

12. 上线检查清单

13. 练习题

  1. 为普通查询、订单创建、报表和 SSE 分别制定 timeout 策略。
  2. 编写统一错误处理中间件,把 connect-timeout 错误转换为稳定 JSON。
  3. 使用 Supertest 验证慢 Route 返回 503,并验证没有二次响应。
  4. 编写可取消的模拟任务,验证 timeout 后 cancelled 为 true、completed 为 false。
  5. 使用原生 node:http 主动销毁客户端请求,验证 Express 能识别连接提前关闭。
  6. 设计一组结构化日志字段,区分应用 deadline、下游 timeout 和客户端断开。
  7. 为当前项目设计 Nginx、Express、MySQL 与前端 timeout 的时间层级。
  8. 选择一个超过 30 秒的接口,将其重构成 202 + jobId 的后台任务流程。

官方参考