瀏覽代碼

fix(proxy): 使用环境变量统一配置 Undici 超时

Haobin DIng 1 月之前
父節點
當前提交
04fb74a14e
共有 5 個文件被更改,包括 207 次插入22 次删除
  1. 18 0
      .env.example
  2. 8 4
      src/app/v1/_lib/proxy/forwarder.ts
  3. 2 2
      src/lib/config/env.schema.ts
  4. 17 16
      src/lib/proxy-agent.ts
  5. 162 0
      tests/unit/lib/undici-timeouts.test.ts

+ 18 - 0
.env.example

@@ -62,6 +62,24 @@ ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS=false
 # - 缩短此值可快速切换到备用供应商,当供应商被攻击或无响应时
 # - 增加此值适用于网络不稳定环境,避免因网络抖动导致连接失败
 FETCH_CONNECT_TIMEOUT=30000
+
+# Fetch 响应头超时配置
+# 功能说明:控制等待响应头的超时时间(通常可近似理解为“等待首字节/首包”的上限)
+# - 默认值:600000 毫秒(600 秒)
+# - 取值范围:建议 10000-600000 毫秒(10-600 秒)
+# 使用场景:
+# - 需要支持长时间首字节等待(例如某些模型/代理的排队或冷启动)时,可适当增大
+# - 希望更快失败并切换供应商时,可适当减小
+FETCH_HEADERS_TIMEOUT=600000
+
+# Fetch 响应体超时配置
+# 功能说明:控制请求/响应体传输超时(undici 会监控 body 数据接收间隔,超时则中断请求)
+# - 默认值:600000 毫秒(600 秒)
+# - 取值范围:建议 10000-600000 毫秒(10-600 秒)
+# 使用场景:
+# - 流式响应或长推理模型:建议保留较大值,避免被 undici 默认 300s 先行终止
+# - 希望快速失败并切换供应商:可适当减小
+FETCH_BODY_TIMEOUT=600000
 MAX_RETRY_ATTEMPTS_DEFAULT=2                # 单供应商最大尝试次数(含首次调用),范围 1-10,留空使用默认值 2
 
 # 智能探测配置

+ 8 - 4
src/app/v1/_lib/proxy/forwarder.ts

@@ -130,9 +130,10 @@ function resolveMaxAttemptsForProvider(
  * 背景:undiciRequest() 在使用非 undici 原生 dispatcher(如 SocksProxyAgent)时,
  * 不会继承全局 Agent 的超时配置,需要显式传递超时参数。
  *
- * 这个值与 proxy-agent.ts 中的 UNDICI_TIMEOUT_MS 保持一致
+ * 这里与全局 undici Agent 使用同一套环境变量配置(FETCH_HEADERS_TIMEOUT / FETCH_BODY_TIMEOUT)
  */
-const UNDICI_REQUEST_TIMEOUT_MS = 600_000; // 600 秒 = 10 分钟,LLM 服务最大超时时间
+// 注意:undici.request 的 headersTimeout/bodyTimeout 属于 RequestOptions;
+// connectTimeout 属于 Dispatcher/Client 配置(已在全局 Agent / ProxyAgent 里处理)。
 
 /**
  * 过滤私有参数(下划线前缀)
@@ -1832,6 +1833,9 @@ export class ProxyForwarder {
     providerName: string,
     session?: ProxySession
   ): Promise<Response> {
+    const { FETCH_HEADERS_TIMEOUT: headersTimeout, FETCH_BODY_TIMEOUT: bodyTimeout } =
+      getEnvConfig();
+
     logger.debug("ProxyForwarder: Using undici.request to bypass auto-decompression", {
       providerId,
       providerName,
@@ -1858,8 +1862,8 @@ export class ProxyForwarder {
       body: init.body as string | Buffer | undefined,
       signal: init.signal,
       dispatcher: init.dispatcher,
-      bodyTimeout: UNDICI_REQUEST_TIMEOUT_MS,
-      headersTimeout: UNDICI_REQUEST_TIMEOUT_MS,
+      bodyTimeout,
+      headersTimeout,
     });
 
     // ⭐ 立即为 undici body 添加错误处理,防止 uncaughtException

+ 2 - 2
src/lib/config/env.schema.ts

@@ -47,8 +47,8 @@ export const EnvSchema = z.object({
     .max(10, "MAX_RETRY_ATTEMPTS_DEFAULT 不能大于 10")
     .default(2),
   // Fetch 超时配置(毫秒)
-  FETCH_BODY_TIMEOUT: z.coerce.number().default(120000), // 请求/响应体传输超时(默认 120 秒)
-  FETCH_HEADERS_TIMEOUT: z.coerce.number().default(60000), // 响应头接收超时(默认 60 秒)
+  FETCH_BODY_TIMEOUT: z.coerce.number().default(600_000), // 请求/响应体传输超时(默认 600 秒)
+  FETCH_HEADERS_TIMEOUT: z.coerce.number().default(600_000), // 响应头接收超时(默认 600 秒)
   FETCH_CONNECT_TIMEOUT: z.coerce.number().default(30000), // TCP 连接建立超时(默认 30 秒)
 });
 

+ 17 - 16
src/lib/proxy-agent.ts

@@ -1,23 +1,24 @@
 import { SocksProxyAgent } from "socks-proxy-agent";
 import { Agent, ProxyAgent, setGlobalDispatcher } from "undici";
 import type { Provider } from "@/types/provider";
-import { logger } from "./logger";
 import { getEnvConfig } from "./config/env.schema";
+import { logger } from "./logger";
 
 /**
  * undici 全局超时配置
  *
  * 背景:undici (Node.js 内置 fetch) 有默认的 300 秒超时 (headersTimeout + bodyTimeout)
  * 问题:即使业务层通过 AbortController 设置更长的超时,undici 的 300 秒会先触发
- * 解决:显式配置 undici 全局超时为 600 秒,匹配 LLM 服务的最大响应时间
+ * 解决:显式配置 undici 全局超时(默认 600 秒,可通过环境变量调整),匹配 LLM 服务的最大响应时间
  *
  * @see https://github.com/nodejs/undici/issues/1373
  * @see https://github.com/nodejs/node/issues/46706
  */
-const UNDICI_TIMEOUT_MS = 600_000; // 600 秒 = 10 分钟,LLM 服务最大超时时间
-
-// 从环境变量读取 TCP 连接超时(默认 30 秒)
-const { FETCH_CONNECT_TIMEOUT: connectTimeout } = getEnvConfig();
+const {
+  FETCH_CONNECT_TIMEOUT: connectTimeout,
+  FETCH_HEADERS_TIMEOUT: headersTimeout,
+  FETCH_BODY_TIMEOUT: bodyTimeout,
+} = getEnvConfig();
 
 /**
  * 设置 undici 全局 Agent,覆盖默认的 300 秒超时
@@ -26,15 +27,15 @@ const { FETCH_CONNECT_TIMEOUT: connectTimeout } = getEnvConfig();
 setGlobalDispatcher(
   new Agent({
     connectTimeout,
-    headersTimeout: UNDICI_TIMEOUT_MS,
-    bodyTimeout: UNDICI_TIMEOUT_MS,
+    headersTimeout,
+    bodyTimeout,
   })
 );
 
 logger.info("undici global dispatcher configured", {
   connectTimeout,
-  headersTimeout: UNDICI_TIMEOUT_MS,
-  bodyTimeout: UNDICI_TIMEOUT_MS,
+  headersTimeout,
+  bodyTimeout,
   note: "覆盖 undici 默认 300s 超时,匹配 LLM 最大响应时间",
 });
 
@@ -105,7 +106,7 @@ export function createProxyAgentForProvider(
       // SOCKS 代理(不支持 HTTP/2)
       // ⭐ 超时说明:
       // - SocksProxyAgent 仅处理 SOCKS 连接建立阶段(默认 30s 超时,足够)
-      // - 连接建立后,HTTP 数据传输由全局 undici Agent 控制(已配置 600s
+      // - 连接建立后,HTTP 数据传输由全局 undici Agent 控制(headersTimeout/bodyTimeout 可配置
       // - 因此 SOCKS 代理无需额外配置 headersTimeout/bodyTimeout
       // @see https://github.com/TooTallNate/node-socks-proxy-agent/issues/26
       agent = new SocksProxyAgent(proxyUrl);
@@ -132,13 +133,13 @@ export function createProxyAgentForProvider(
     } else if (parsedProxy.protocol === "http:" || parsedProxy.protocol === "https:") {
       // HTTP/HTTPS 代理(使用 undici)
       // 支持 HTTP/2:通过 allowH2 选项启用 ALPN 协商
-      // ⭐ 配置超时,覆盖 undici 默认值,匹配 LLM 最大响应时间
+      // ⭐ 配置超时,覆盖 undici 默认值,匹配 LLM 最大响应时间(默认 600 秒,可通过环境变量调整)
       agent = new ProxyAgent({
         uri: proxyUrl,
         allowH2: enableHttp2,
         connectTimeout,
-        headersTimeout: UNDICI_TIMEOUT_MS, // 等待响应头的超时
-        bodyTimeout: UNDICI_TIMEOUT_MS, // 等待响应体的超时
+        headersTimeout, // 等待响应头的超时
+        bodyTimeout, // 等待响应体的超时
       });
       actualHttp2Enabled = enableHttp2;
       logger.debug("HTTP/HTTPS ProxyAgent created", {
@@ -150,8 +151,8 @@ export function createProxyAgentForProvider(
         targetUrl: new URL(targetUrl).origin,
         http2Enabled: enableHttp2,
         connectTimeout,
-        headersTimeout: UNDICI_TIMEOUT_MS,
-        bodyTimeout: UNDICI_TIMEOUT_MS,
+        headersTimeout,
+        bodyTimeout,
       });
     } else {
       throw new Error(

+ 162 - 0
tests/unit/lib/undici-timeouts.test.ts

@@ -0,0 +1,162 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+const undiciMocks = vi.hoisted(() => {
+  return {
+    Agent: vi.fn(),
+    ProxyAgent: vi.fn(),
+    setGlobalDispatcher: vi.fn(),
+    request: vi.fn(),
+    fetch: vi.fn(),
+  };
+});
+
+vi.mock("undici", () => undiciMocks);
+
+const ORIGINAL_ENV = {
+  FETCH_CONNECT_TIMEOUT: process.env.FETCH_CONNECT_TIMEOUT,
+  FETCH_HEADERS_TIMEOUT: process.env.FETCH_HEADERS_TIMEOUT,
+  FETCH_BODY_TIMEOUT: process.env.FETCH_BODY_TIMEOUT,
+};
+
+function restoreEnv() {
+  if (ORIGINAL_ENV.FETCH_CONNECT_TIMEOUT === undefined) {
+    delete process.env.FETCH_CONNECT_TIMEOUT;
+  } else {
+    process.env.FETCH_CONNECT_TIMEOUT = ORIGINAL_ENV.FETCH_CONNECT_TIMEOUT;
+  }
+
+  if (ORIGINAL_ENV.FETCH_HEADERS_TIMEOUT === undefined) {
+    delete process.env.FETCH_HEADERS_TIMEOUT;
+  } else {
+    process.env.FETCH_HEADERS_TIMEOUT = ORIGINAL_ENV.FETCH_HEADERS_TIMEOUT;
+  }
+
+  if (ORIGINAL_ENV.FETCH_BODY_TIMEOUT === undefined) {
+    delete process.env.FETCH_BODY_TIMEOUT;
+  } else {
+    process.env.FETCH_BODY_TIMEOUT = ORIGINAL_ENV.FETCH_BODY_TIMEOUT;
+  }
+}
+
+afterEach(() => {
+  restoreEnv();
+});
+
+describe("EnvSchema - Fetch 超时配置", () => {
+  it("未配置环境变量时,应使用 600s 的 headers/body 默认值(保持历史行为)", async () => {
+    delete process.env.FETCH_CONNECT_TIMEOUT;
+    delete process.env.FETCH_HEADERS_TIMEOUT;
+    delete process.env.FETCH_BODY_TIMEOUT;
+
+    vi.resetModules();
+    const { getEnvConfig } = await import("@/lib/config/env.schema");
+    const env = getEnvConfig();
+
+    expect(env.FETCH_CONNECT_TIMEOUT).toBe(30000);
+    expect(env.FETCH_HEADERS_TIMEOUT).toBe(600000);
+    expect(env.FETCH_BODY_TIMEOUT).toBe(600000);
+  });
+
+  it("配置环境变量时,应正确解析三类超时(毫秒)", async () => {
+    process.env.FETCH_CONNECT_TIMEOUT = "111";
+    process.env.FETCH_HEADERS_TIMEOUT = "222";
+    process.env.FETCH_BODY_TIMEOUT = "333";
+
+    vi.resetModules();
+    const { getEnvConfig } = await import("@/lib/config/env.schema");
+    const env = getEnvConfig();
+
+    expect(env.FETCH_CONNECT_TIMEOUT).toBe(111);
+    expect(env.FETCH_HEADERS_TIMEOUT).toBe(222);
+    expect(env.FETCH_BODY_TIMEOUT).toBe(333);
+  });
+});
+
+describe("Undici - 超时参数注入", () => {
+  it("proxy-agent.ts 应将 headers/body/connect timeout 从 env 注入到全局 Agent", async () => {
+    process.env.FETCH_CONNECT_TIMEOUT = "1000";
+    process.env.FETCH_HEADERS_TIMEOUT = "2000";
+    process.env.FETCH_BODY_TIMEOUT = "3000";
+
+    vi.resetModules();
+    undiciMocks.Agent.mockClear();
+    undiciMocks.setGlobalDispatcher.mockClear();
+
+    await import("@/lib/proxy-agent");
+
+    expect(undiciMocks.Agent).toHaveBeenCalledWith(
+      expect.objectContaining({
+        connectTimeout: 1000,
+        headersTimeout: 2000,
+        bodyTimeout: 3000,
+      })
+    );
+    expect(undiciMocks.setGlobalDispatcher).toHaveBeenCalledTimes(1);
+  });
+
+  it("createProxyAgentForProvider 应将 headers/body/connect timeout 从 env 注入到 ProxyAgent", async () => {
+    process.env.FETCH_CONNECT_TIMEOUT = "4000";
+    process.env.FETCH_HEADERS_TIMEOUT = "5000";
+    process.env.FETCH_BODY_TIMEOUT = "6000";
+
+    vi.resetModules();
+    undiciMocks.ProxyAgent.mockClear();
+
+    const { createProxyAgentForProvider } = await import("@/lib/proxy-agent");
+
+    createProxyAgentForProvider(
+      {
+        id: 1,
+        name: "test-provider",
+        proxyUrl: "http://user:[email protected]:8080",
+        proxyFallbackToDirect: false,
+      },
+      "https://example.com/v1/messages",
+      true
+    );
+
+    expect(undiciMocks.ProxyAgent).toHaveBeenCalledWith(
+      expect.objectContaining({
+        uri: "http://user:[email protected]:8080",
+        allowH2: true,
+        connectTimeout: 4000,
+        headersTimeout: 5000,
+        bodyTimeout: 6000,
+      })
+    );
+  });
+
+  it("forwarder.ts 的 undici.request 路径应显式传递 headers/body timeout", async () => {
+    process.env.FETCH_CONNECT_TIMEOUT = "7000";
+    process.env.FETCH_HEADERS_TIMEOUT = "8000";
+    process.env.FETCH_BODY_TIMEOUT = "9000";
+
+    vi.resetModules();
+    undiciMocks.request.mockImplementation(() => {
+      throw new Error("boom");
+    });
+
+    const { ProxyForwarder } = await import("@/app/v1/_lib/proxy/forwarder");
+    undiciMocks.request.mockClear();
+
+    await expect(
+      (ProxyForwarder as any).fetchWithoutAutoDecode(
+        "https://example.com/v1/messages",
+        {
+          method: "POST",
+          headers: new Headers([["content-type", "application/json"]]),
+        },
+        1,
+        "test-provider"
+      )
+    ).rejects.toThrow("boom");
+
+    expect(undiciMocks.request).toHaveBeenCalledWith(
+      "https://example.com/v1/messages",
+      expect.objectContaining({
+        headersTimeout: 8000,
+        bodyTimeout: 9000,
+      })
+    );
+  });
+});