From ba223c776634963d5226c1f901487685e93859f7 Mon Sep 17 00:00:00 2001 From: Ayane <40628300+ayanesakura@users.noreply.github.com> Date: Fri, 6 Mar 2026 00:46:10 +0800 Subject: [PATCH] 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 --- extensions/feishu/src/client.test.ts | 73 +++++++++++++++++++++++++++- extensions/feishu/src/client.ts | 30 +++++++++++- 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/extensions/feishu/src/client.test.ts b/extensions/feishu/src/client.test.ts index e7a9e097082..f0394afc5bf 100644 --- a/extensions/feishu/src/client.test.ts +++ b/extensions/feishu/src/client.test.ts @@ -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).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).mock.calls; + const lastCall = calls[calls.length - 1][0] as { + httpInstance: { post: (...args: unknown[]) => Promise }; + }; + 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).mock.calls; + const lastCall = calls[calls.length - 1][0] as { + httpInstance: { get: (...args: unknown[]) => Promise }; + }; + 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); diff --git a/extensions/feishu/src/client.ts b/extensions/feishu/src/client.ts index 569a48313c9..6152251eccd 100644 --- a/extensions/feishu/src/client.ts +++ b/extensions/feishu/src/client.ts @@ -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 | 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(opts?: Lark.HttpRequestOptions): Lark.HttpRequestOptions { + return { timeout: FEISHU_HTTP_TIMEOUT_MS, ...opts } as Lark.HttpRequestOptions; + } + + 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