Skip to content

Commit 5ec9f3b

Browse files
Merge pull request #259 from modelstudioai/fix/http-headers-timeout
fix(core): honor configured request timeout for response headers
2 parents 44a7330 + fd5f408 commit 5ec9f3b

5 files changed

Lines changed: 139 additions & 2 deletions

File tree

‎docs/agents/command-flag-change.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ pnpm -F bailian-cli exec tsx src/main.ts <command> --new-flag x # 实测一遍
5353

5454
## 常见漏点
5555

56+
- 修改请求超时时,检查底层 HTTP 限制:core 的公共 HTTP 请求层仅为已设置超时计时器的请求关闭 `headersTimeout`,由请求 `AbortSignal` 控制响应头等待;保留 `bodyTimeout` 空闲保护,不全局关闭其他请求的超时。仅设置 Agent 默认值会被 Node `fetch` 的逐请求默认值覆盖。流式读取的总时长另由调用方管理,不能据此宣称 `--timeout` 覆盖整个流。
5657
- ✗ 加了 array 型 flag 但没考虑用户可能传多次
5758
- ✗ 改默认值忘记更新 description 里的 "(default: xxx)" 文案
5859
- ✗ 在 `usageArgs` / `exampleArgs` 里写死 `bl <path>`,导致其它产品入口复用时 help 错

‎packages/core/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
},
4242
"dependencies": {
4343
"tar-stream": "catalog:",
44+
"undici": "catalog:",
4445
"yaml": "^2.8.3",
4546
"yauzl": "catalog:"
4647
},

‎packages/core/src/client/http.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ import { ExitCode } from "../errors/codes.ts";
55
import { mapApiError } from "../errors/api.ts";
66
import { maskToken } from "../utils/token.ts";
77
import { sourceConfig, trackingHeaders } from "./headers.ts";
8+
import { Dispatcher, getGlobalDispatcher } from "undici";
9+
10+
// 请求已由 --timeout 对应的 signal 控制超时。这里在实际发送前关闭底层默认的
11+
// 300 秒响应头超时,避免用户设置更长等待时间后,请求仍在 300 秒时被提前中断。
12+
// 只覆盖 headersTimeout,响应体的空闲超时保持原值。
13+
const withoutHeadersTimeout: Dispatcher.DispatcherComposeInterceptor =
14+
(dispatch) => (options, handler) =>
15+
dispatch({ ...options, headersTimeout: 0 }, handler);
816

917
/** 传输层依赖:UA 用 identity,timeout/verbose 用 settings。凭证由调用方(Client)注头。 */
1018
export interface HttpDeps {
@@ -77,7 +85,7 @@ async function performRequest(
7785
console.error(`> x-dashscope-source-config: ${sourceConfig(deps.identity)}`);
7886
}
7987

80-
const res = await fetch(opts.url, {
88+
const requestInit = {
8189
method: opts.method ?? "GET",
8290
headers,
8391
body: opts.body
@@ -86,7 +94,14 @@ async function performRequest(
8694
: JSON.stringify(opts.body)
8795
: undefined,
8896
signal,
89-
});
97+
// 复用当前客户端,仅关闭本次请求的响应头超时。
98+
// 从原型调用 compose,兼容没有 compose 方法的客户端;类型断言适配 Node 的 fetch 类型。
99+
dispatcher: Dispatcher.prototype.compose.call(
100+
getGlobalDispatcher(),
101+
withoutHeadersTimeout,
102+
) as unknown as RequestInit["dispatcher"],
103+
};
104+
const res = await fetch(opts.url, requestInit);
90105

91106
if (deps.settings.verbose) {
92107
console.error(`< ${res.status} ${res.statusText}`);
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { execFile } from "node:child_process";
2+
import { createServer, type Server } from "node:http";
3+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4+
import type { AddressInfo } from "node:net";
5+
import { tmpdir } from "node:os";
6+
import { dirname, join } from "node:path";
7+
import { fileURLToPath } from "node:url";
8+
import { promisify } from "node:util";
9+
import { afterAll, beforeAll, expect, test } from "vite-plus/test";
10+
import { resolveTsxBin } from "e2e/runner";
11+
12+
const execFileAsync = promisify(execFile);
13+
const runtimeRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
14+
let server: Server;
15+
let serverUrl: string;
16+
let scriptDir: string;
17+
let scriptPath: string;
18+
19+
beforeAll(async () => {
20+
server = createServer((request, response) => {
21+
if (request.url === "/body") {
22+
response.write("first");
23+
}
24+
const timer = setTimeout(() => response.end("done"), 2500);
25+
response.on("close", () => clearTimeout(timer));
26+
});
27+
await new Promise<void>((resolve, reject) => {
28+
server.once("error", reject);
29+
server.listen(0, "127.0.0.1", resolve);
30+
});
31+
serverUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
32+
scriptDir = mkdtempSync(join(tmpdir(), "bl-http-timeout-"));
33+
scriptPath = join(scriptDir, "probe.mts");
34+
writeFileSync(
35+
scriptPath,
36+
`
37+
import { Agent, setGlobalDispatcher } from ${JSON.stringify(join(runtimeRoot, "node_modules/undici/index.js"))};
38+
import { setupProxyFromEnv } from ${JSON.stringify(join(runtimeRoot, "src/proxy.ts"))};
39+
import { request } from ${JSON.stringify(join(runtimeRoot, "../core/src/client/http.ts"))};
40+
// Scale the hidden transport limit down while keeping real sockets and native fetch.
41+
const transport = new Agent().compose((dispatch) => (options, handler) =>
42+
dispatch({ ...options, headersTimeout: options.headersTimeout === 0 ? 0 : 100,
43+
bodyTimeout: options.bodyTimeout === 0 ? 0 : 100 }, handler));
44+
setGlobalDispatcher(process.argv[4] === "custom" ? {
45+
dispatch: transport.dispatch.bind(transport),
46+
close: transport.close.bind(transport),
47+
destroy: transport.destroy.bind(transport),
48+
} : transport);
49+
setupProxyFromEnv();
50+
try {
51+
const response = process.argv[5] === "raw"
52+
? await fetch(process.argv[2])
53+
: await request({
54+
identity: { clientName: "timeout-test", version: "0.0.0" },
55+
settings: { timeout: Number(process.argv[3]) / 1000, verbose: false },
56+
}, { url: process.argv[2] });
57+
console.log(await response.text());
58+
} catch (error) {
59+
console.log(error.cause?.code ?? error.name);
60+
} finally {
61+
await transport.destroy();
62+
}
63+
`,
64+
);
65+
});
66+
67+
afterAll(async () => {
68+
server.closeAllConnections();
69+
await new Promise<void>((resolve) => server.close(() => resolve()));
70+
if (scriptDir) rmSync(scriptDir, { recursive: true, force: true });
71+
});
72+
73+
async function runProbe(
74+
path: string,
75+
timeoutMs: number,
76+
dispatcher = "default",
77+
mode = "request",
78+
): Promise<string> {
79+
const { stdout } = await execFileAsync(
80+
resolveTsxBin(),
81+
[scriptPath, `${serverUrl}${path}`, String(timeoutMs), dispatcher, mode],
82+
{
83+
cwd: runtimeRoot,
84+
timeout: 10_000,
85+
env: {
86+
...process.env,
87+
HTTP_PROXY: "",
88+
HTTPS_PROXY: "",
89+
NO_PROXY: "",
90+
http_proxy: "",
91+
https_proxy: "",
92+
no_proxy: "",
93+
},
94+
},
95+
);
96+
return stdout.trim();
97+
}
98+
99+
test("delayed headers can outlive the transport limit when the request deadline allows it", async () => {
100+
expect(await runProbe("/headers", 6000)).toBe("done");
101+
});
102+
103+
test("the request deadline still aborts delayed headers", async () => {
104+
expect(await runProbe("/headers", 100)).toBe("AbortError");
105+
});
106+
107+
test("unrelated fetch without a deadline keeps its headers timeout", async () => {
108+
expect(await runProbe("/headers", 6000, "default", "raw")).toBe("UND_ERR_HEADERS_TIMEOUT");
109+
});
110+
111+
test("existing custom dispatchers do not need a compose method", async () => {
112+
expect(await runProbe("/headers", 6000, "custom")).toBe("done");
113+
});
114+
115+
test("body idle timeout remains active after headers arrive", async () => {
116+
expect(await runProbe("/body", 6000)).toBe("UND_ERR_BODY_TIMEOUT");
117+
});

‎pnpm-lock.yaml‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)