Skip to content

Commit ba223c7

Browse files
ayanesakuraAyane
andauthored
fix(feishu): add HTTP timeout to prevent per-chat queue deadlocks (openclaw#36430)
When the Feishu API hangs or responds slowly, the sendChain never settles, causing the per-chat queue to remain in a processing state forever and blocking all subsequent messages in that thread. This adds a 30-second default timeout to all Feishu HTTP requests by providing a timeout-aware httpInstance to the Lark SDK client. Closes openclaw#36412 Co-authored-by: Ayane <wangruofei@soulapp.cn>
1 parent 8d48235 commit ba223c7

2 files changed

Lines changed: 101 additions & 2 deletions

File tree

extensions/feishu/src/client.test.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,38 @@ const httpsProxyAgentCtorMock = vi.hoisted(() =>
1212
}),
1313
);
1414

15+
const mockBaseHttpInstance = vi.hoisted(() => ({
16+
request: vi.fn().mockResolvedValue({}),
17+
get: vi.fn().mockResolvedValue({}),
18+
post: vi.fn().mockResolvedValue({}),
19+
put: vi.fn().mockResolvedValue({}),
20+
patch: vi.fn().mockResolvedValue({}),
21+
delete: vi.fn().mockResolvedValue({}),
22+
head: vi.fn().mockResolvedValue({}),
23+
options: vi.fn().mockResolvedValue({}),
24+
}));
25+
1526
vi.mock("@larksuiteoapi/node-sdk", () => ({
1627
AppType: { SelfBuild: "self" },
1728
Domain: { Feishu: "https://open.feishu.cn", Lark: "https://open.larksuite.com" },
1829
LoggerLevel: { info: "info" },
1930
Client: vi.fn(),
2031
WSClient: wsClientCtorMock,
2132
EventDispatcher: vi.fn(),
33+
defaultHttpInstance: mockBaseHttpInstance,
2234
}));
2335

2436
vi.mock("https-proxy-agent", () => ({
2537
HttpsProxyAgent: httpsProxyAgentCtorMock,
2638
}));
2739

28-
import { createFeishuWSClient } from "./client.js";
40+
import { Client as LarkClient } from "@larksuiteoapi/node-sdk";
41+
import {
42+
createFeishuClient,
43+
createFeishuWSClient,
44+
clearClientCache,
45+
FEISHU_HTTP_TIMEOUT_MS,
46+
} from "./client.js";
2947

3048
const proxyEnvKeys = ["https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"] as const;
3149
type ProxyEnvKey = (typeof proxyEnvKeys)[number];
@@ -68,6 +86,59 @@ afterEach(() => {
6886
}
6987
});
7088

89+
describe("createFeishuClient HTTP timeout", () => {
90+
beforeEach(() => {
91+
clearClientCache();
92+
});
93+
94+
it("passes a custom httpInstance with default timeout to Lark.Client", () => {
95+
createFeishuClient({ appId: "app_1", appSecret: "secret_1", accountId: "timeout-test" });
96+
97+
const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
98+
const lastCall = calls[calls.length - 1][0] as { httpInstance?: unknown };
99+
expect(lastCall.httpInstance).toBeDefined();
100+
});
101+
102+
it("injects default timeout into HTTP request options", async () => {
103+
createFeishuClient({ appId: "app_2", appSecret: "secret_2", accountId: "timeout-inject" });
104+
105+
const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
106+
const lastCall = calls[calls.length - 1][0] as {
107+
httpInstance: { post: (...args: unknown[]) => Promise<unknown> };
108+
};
109+
const httpInstance = lastCall.httpInstance;
110+
111+
await httpInstance.post(
112+
"https://example.com/api",
113+
{ data: 1 },
114+
{ headers: { "X-Custom": "yes" } },
115+
);
116+
117+
expect(mockBaseHttpInstance.post).toHaveBeenCalledWith(
118+
"https://example.com/api",
119+
{ data: 1 },
120+
expect.objectContaining({ timeout: FEISHU_HTTP_TIMEOUT_MS, headers: { "X-Custom": "yes" } }),
121+
);
122+
});
123+
124+
it("allows explicit timeout override per-request", async () => {
125+
createFeishuClient({ appId: "app_3", appSecret: "secret_3", accountId: "timeout-override" });
126+
127+
const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
128+
const lastCall = calls[calls.length - 1][0] as {
129+
httpInstance: { get: (...args: unknown[]) => Promise<unknown> };
130+
};
131+
const httpInstance = lastCall.httpInstance;
132+
133+
await httpInstance.get("https://example.com/api", { timeout: 5_000 });
134+
135+
expect(mockBaseHttpInstance.get).toHaveBeenCalledWith(
136+
"https://example.com/api",
137+
expect.objectContaining({ timeout: 5_000 }),
138+
);
139+
});
140+
});
141+
71142
describe("createFeishuWSClient proxy handling", () => {
72143
it("does not set a ws proxy agent when proxy env is absent", () => {
73144
createFeishuWSClient(baseAccount);

extensions/feishu/src/client.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import * as Lark from "@larksuiteoapi/node-sdk";
22
import { HttpsProxyAgent } from "https-proxy-agent";
33
import type { FeishuDomain, ResolvedFeishuAccount } from "./types.js";
44

5+
/** Default HTTP timeout for Feishu API requests (30 seconds). */
6+
export const FEISHU_HTTP_TIMEOUT_MS = 30_000;
7+
58
function getWsProxyAgent(): HttpsProxyAgent<string> | undefined {
69
const proxyUrl =
710
process.env.https_proxy ||
@@ -31,6 +34,30 @@ function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string {
3134
return domain.replace(/\/+$/, ""); // Custom URL for private deployment
3235
}
3336

37+
/**
38+
* Create an HTTP instance that delegates to the Lark SDK's default instance
39+
* but injects a default request timeout to prevent indefinite hangs
40+
* (e.g. when the Feishu API is slow, causing per-chat queue deadlocks).
41+
*/
42+
function createTimeoutHttpInstance(): Lark.HttpInstance {
43+
const base: Lark.HttpInstance = Lark.defaultHttpInstance as unknown as Lark.HttpInstance;
44+
45+
function injectTimeout<D>(opts?: Lark.HttpRequestOptions<D>): Lark.HttpRequestOptions<D> {
46+
return { timeout: FEISHU_HTTP_TIMEOUT_MS, ...opts } as Lark.HttpRequestOptions<D>;
47+
}
48+
49+
return {
50+
request: (opts) => base.request(injectTimeout(opts)),
51+
get: (url, opts) => base.get(url, injectTimeout(opts)),
52+
post: (url, data, opts) => base.post(url, data, injectTimeout(opts)),
53+
put: (url, data, opts) => base.put(url, data, injectTimeout(opts)),
54+
patch: (url, data, opts) => base.patch(url, data, injectTimeout(opts)),
55+
delete: (url, opts) => base.delete(url, injectTimeout(opts)),
56+
head: (url, opts) => base.head(url, injectTimeout(opts)),
57+
options: (url, opts) => base.options(url, injectTimeout(opts)),
58+
};
59+
}
60+
3461
/**
3562
* Credentials needed to create a Feishu client.
3663
* Both FeishuConfig and ResolvedFeishuAccount satisfy this interface.
@@ -64,12 +91,13 @@ export function createFeishuClient(creds: FeishuClientCredentials): Lark.Client
6491
return cached.client;
6592
}
6693

67-
// Create new client
94+
// Create new client with timeout-aware HTTP instance
6895
const client = new Lark.Client({
6996
appId,
7097
appSecret,
7198
appType: Lark.AppType.SelfBuild,
7299
domain: resolveDomain(domain),
100+
httpInstance: createTimeoutHttpInstance(),
73101
});
74102

75103
// Cache it

0 commit comments

Comments
 (0)