Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
fix(feishu): bound streaming-card API requests with a 30s timeout
  • Loading branch information
NIO
NIO committed Jul 11, 2026
commit b1dcdaa5f102aa32188b4d0abf1946f367165aba
61 changes: 61 additions & 0 deletions extensions/feishu/src/streaming-card.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,67 @@ describe("FeishuStreamingSession", () => {
);
});

it("aborts a stalled Feishu tenant-token request after 30 seconds", async () => {
vi.useFakeTimers();
let authRequestReceived = false;
const deps: StreamingFetchDeps = {
fetchImpl: withFetchPreconnect(
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(input instanceof Request ? input.url : input.toString());
if (!url.pathname.includes("/auth/")) {
return jsonResponse({ code: 0, msg: "ok", data: { card_id: "card_should_not_reach" } });
}
authRequestReceived = true;
return await new Promise<Response>((_resolve, reject) => {
const signal = init?.signal;
if (!signal) {
reject(new Error("missing guarded fetch signal"));
return;
}
signal.addEventListener(
"abort",
() => {
const reason = signal.reason;
reject(
reason instanceof Error
? reason
: new Error("request aborted", { cause: reason }),
);
},
{ once: true },
);
});
}),
) as FeishuStreamingFetch,
lookupFn: hermeticPublicLookup,
};

const session = new FeishuStreamingSession(
{} as never,
{
appId: "app_stalled_token",
appSecret: "secret",
},
undefined,
deps,
);

const result = expect(session.start("chat_id", "open_id")).rejects.toSatisfy(
(error: unknown) => {
expect(error).toBeInstanceOf(Error);
const message = (error as Error).message;
expect(message).toMatch(/timed out/i);
return true;
},
);
await vi.advanceTimersByTimeAsync(30_001);
await result;
expect(authRequestReceived).toBe(true);
console.log(
"[feishu streaming-card stall proof] stalled tenant-token fetch aborted after 30000ms",
);
});

it("rejects oversized streaming card-create JSON before buffering the full body", async () => {
let streamState:
| {
Expand Down
11 changes: 11 additions & 0 deletions extensions/feishu/src/streaming-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import { readFeishuJsonResponse } from "./json-response.js";
import { resolveFeishuCardTemplate, type CardHeaderConfig } from "./send.js";
import type { FeishuDomain } from "./types.js";

// All CardKit and token requests share a single deadline. Without this, a
// server that accepts the TCP connection but stalls blocks the streaming-card
// reply lane and pins a gateway request open indefinitely.
const FEISHU_STREAMING_CARD_REQUEST_TIMEOUT_MS = 30_000;

type Credentials = { appId: string; appSecret: string; domain?: FeishuDomain };
type CardState = {
cardId: string;
Expand Down Expand Up @@ -140,6 +145,7 @@ async function getToken(creds: Credentials, deps?: FeishuStreamingDeps): Promise
lookupFn: deps?.lookupFn,
policy: { allowedHostnames: resolveAllowedHostnames(creds.domain) },
auditContext: "feishu.streaming-card.token",
timeoutMs: FEISHU_STREAMING_CARD_REQUEST_TIMEOUT_MS,
});
let data: {
code: number;
Expand Down Expand Up @@ -320,6 +326,7 @@ export class FeishuStreamingSession {
lookupFn: this.lookupFn,
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
auditContext: "feishu.streaming-card.create",
timeoutMs: FEISHU_STREAMING_CARD_REQUEST_TIMEOUT_MS,
});
let createData: {
code: number;
Expand Down Expand Up @@ -434,6 +441,7 @@ export class FeishuStreamingSession {
lookupFn: this.lookupFn,
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
auditContext: "feishu.streaming-card.update",
timeoutMs: FEISHU_STREAMING_CARD_REQUEST_TIMEOUT_MS,
});
try {
await assertSuccessfulCardKitResponse(
Expand Down Expand Up @@ -483,6 +491,7 @@ export class FeishuStreamingSession {
lookupFn: this.lookupFn,
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
auditContext: "feishu.streaming-card.replace",
timeoutMs: FEISHU_STREAMING_CARD_REQUEST_TIMEOUT_MS,
});
try {
await assertSuccessfulCardKitResponse(
Expand Down Expand Up @@ -595,6 +604,7 @@ export class FeishuStreamingSession {
lookupFn: this.lookupFn,
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
auditContext: "feishu.streaming-card.note-update",
timeoutMs: FEISHU_STREAMING_CARD_REQUEST_TIMEOUT_MS,
})
.then(async ({ response, release }) => {
try {
Expand Down Expand Up @@ -672,6 +682,7 @@ export class FeishuStreamingSession {
lookupFn: this.lookupFn,
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
auditContext: "feishu.streaming-card.close",
timeoutMs: FEISHU_STREAMING_CARD_REQUEST_TIMEOUT_MS,
})
.then(async ({ response, release }) => {
try {
Expand Down
Loading