mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(feishu): add HTTP timeout to prevent per-chat queue deadlocks (#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 #36412 Co-authored-by: Ayane <wangruofei@soulapp.cn>
This commit is contained in:
@@ -12,6 +12,17 @@ const httpsProxyAgentCtorMock = vi.hoisted(() =>
|
||||
}),
|
||||
);
|
||||
|
||||
const mockBaseHttpInstance = vi.hoisted(() => ({
|
||||
request: vi.fn().mockResolvedValue({}),
|
||||
get: vi.fn().mockResolvedValue({}),
|
||||
post: vi.fn().mockResolvedValue({}),
|
||||
put: vi.fn().mockResolvedValue({}),
|
||||
patch: vi.fn().mockResolvedValue({}),
|
||||
delete: vi.fn().mockResolvedValue({}),
|
||||
head: vi.fn().mockResolvedValue({}),
|
||||
options: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("@larksuiteoapi/node-sdk", () => ({
|
||||
AppType: { SelfBuild: "self" },
|
||||
Domain: { Feishu: "https://open.feishu.cn", Lark: "https://open.larksuite.com" },
|
||||
@@ -19,13 +30,20 @@ vi.mock("@larksuiteoapi/node-sdk", () => ({
|
||||
Client: vi.fn(),
|
||||
WSClient: wsClientCtorMock,
|
||||
EventDispatcher: vi.fn(),
|
||||
defaultHttpInstance: mockBaseHttpInstance,
|
||||
}));
|
||||
|
||||
vi.mock("https-proxy-agent", () => ({
|
||||
HttpsProxyAgent: httpsProxyAgentCtorMock,
|
||||
}));
|
||||
|
||||
import { createFeishuWSClient } from "./client.js";
|
||||
import { Client as LarkClient } from "@larksuiteoapi/node-sdk";
|
||||
import {
|
||||
createFeishuClient,
|
||||
createFeishuWSClient,
|
||||
clearClientCache,
|
||||
FEISHU_HTTP_TIMEOUT_MS,
|
||||
} from "./client.js";
|
||||
|
||||
const proxyEnvKeys = ["https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"] as const;
|
||||
type ProxyEnvKey = (typeof proxyEnvKeys)[number];
|
||||
@@ -68,6 +86,59 @@ afterEach(() => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("createFeishuClient HTTP timeout", () => {
|
||||
beforeEach(() => {
|
||||
clearClientCache();
|
||||
});
|
||||
|
||||
it("passes a custom httpInstance with default timeout to Lark.Client", () => {
|
||||
createFeishuClient({ appId: "app_1", appSecret: "secret_1", accountId: "timeout-test" });
|
||||
|
||||
const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const lastCall = calls[calls.length - 1][0] as { httpInstance?: unknown };
|
||||
expect(lastCall.httpInstance).toBeDefined();
|
||||
});
|
||||
|
||||
it("injects default timeout into HTTP request options", async () => {
|
||||
createFeishuClient({ appId: "app_2", appSecret: "secret_2", accountId: "timeout-inject" });
|
||||
|
||||
const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const lastCall = calls[calls.length - 1][0] as {
|
||||
httpInstance: { post: (...args: unknown[]) => Promise<unknown> };
|
||||
};
|
||||
const httpInstance = lastCall.httpInstance;
|
||||
|
||||
await httpInstance.post(
|
||||
"https://example.com/api",
|
||||
{ data: 1 },
|
||||
{ headers: { "X-Custom": "yes" } },
|
||||
);
|
||||
|
||||
expect(mockBaseHttpInstance.post).toHaveBeenCalledWith(
|
||||
"https://example.com/api",
|
||||
{ data: 1 },
|
||||
expect.objectContaining({ timeout: FEISHU_HTTP_TIMEOUT_MS, headers: { "X-Custom": "yes" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows explicit timeout override per-request", async () => {
|
||||
createFeishuClient({ appId: "app_3", appSecret: "secret_3", accountId: "timeout-override" });
|
||||
|
||||
const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const lastCall = calls[calls.length - 1][0] as {
|
||||
httpInstance: { get: (...args: unknown[]) => Promise<unknown> };
|
||||
};
|
||||
const httpInstance = lastCall.httpInstance;
|
||||
|
||||
await httpInstance.get("https://example.com/api", { timeout: 5_000 });
|
||||
|
||||
expect(mockBaseHttpInstance.get).toHaveBeenCalledWith(
|
||||
"https://example.com/api",
|
||||
expect.objectContaining({ timeout: 5_000 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFeishuWSClient proxy handling", () => {
|
||||
it("does not set a ws proxy agent when proxy env is absent", () => {
|
||||
createFeishuWSClient(baseAccount);
|
||||
|
||||
@@ -2,6 +2,9 @@ import * as Lark from "@larksuiteoapi/node-sdk";
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
import type { FeishuDomain, ResolvedFeishuAccount } from "./types.js";
|
||||
|
||||
/** Default HTTP timeout for Feishu API requests (30 seconds). */
|
||||
export const FEISHU_HTTP_TIMEOUT_MS = 30_000;
|
||||
|
||||
function getWsProxyAgent(): HttpsProxyAgent<string> | undefined {
|
||||
const proxyUrl =
|
||||
process.env.https_proxy ||
|
||||
@@ -31,6 +34,30 @@ function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string {
|
||||
return domain.replace(/\/+$/, ""); // Custom URL for private deployment
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an HTTP instance that delegates to the Lark SDK's default instance
|
||||
* but injects a default request timeout to prevent indefinite hangs
|
||||
* (e.g. when the Feishu API is slow, causing per-chat queue deadlocks).
|
||||
*/
|
||||
function createTimeoutHttpInstance(): Lark.HttpInstance {
|
||||
const base: Lark.HttpInstance = Lark.defaultHttpInstance as unknown as Lark.HttpInstance;
|
||||
|
||||
function injectTimeout<D>(opts?: Lark.HttpRequestOptions<D>): Lark.HttpRequestOptions<D> {
|
||||
return { timeout: FEISHU_HTTP_TIMEOUT_MS, ...opts } as Lark.HttpRequestOptions<D>;
|
||||
}
|
||||
|
||||
return {
|
||||
request: (opts) => base.request(injectTimeout(opts)),
|
||||
get: (url, opts) => base.get(url, injectTimeout(opts)),
|
||||
post: (url, data, opts) => base.post(url, data, injectTimeout(opts)),
|
||||
put: (url, data, opts) => base.put(url, data, injectTimeout(opts)),
|
||||
patch: (url, data, opts) => base.patch(url, data, injectTimeout(opts)),
|
||||
delete: (url, opts) => base.delete(url, injectTimeout(opts)),
|
||||
head: (url, opts) => base.head(url, injectTimeout(opts)),
|
||||
options: (url, opts) => base.options(url, injectTimeout(opts)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Credentials needed to create a Feishu client.
|
||||
* Both FeishuConfig and ResolvedFeishuAccount satisfy this interface.
|
||||
@@ -64,12 +91,13 @@ export function createFeishuClient(creds: FeishuClientCredentials): Lark.Client
|
||||
return cached.client;
|
||||
}
|
||||
|
||||
// Create new client
|
||||
// Create new client with timeout-aware HTTP instance
|
||||
const client = new Lark.Client({
|
||||
appId,
|
||||
appSecret,
|
||||
appType: Lark.AppType.SelfBuild,
|
||||
domain: resolveDomain(domain),
|
||||
httpInstance: createTimeoutHttpInstance(),
|
||||
});
|
||||
|
||||
// Cache it
|
||||
|
||||
Reference in New Issue
Block a user