diff --git a/adapters/dingtalk/__tests__/ai-card.test.ts b/adapters/dingtalk/__tests__/ai-card.test.ts index bc2658bb..58a18248 100644 --- a/adapters/dingtalk/__tests__/ai-card.test.ts +++ b/adapters/dingtalk/__tests__/ai-card.test.ts @@ -61,4 +61,41 @@ describe('DingTalk AI Card streaming', () => { expect(calls[0]!.body.isFinalize).toBe(true) expect(calls[1]!.body.cardData.cardParamMap.flowStatus).toBe('3') }) + + it('times out a hung card streaming request', async () => { + const previousTimeout = process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS + process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS = '20' + globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ + url: String(url), + method: init?.method ?? 'GET', + body: init?.body ? JSON.parse(String(init.body)) : null, + }) + return await new Promise((_, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new DOMException('aborted', 'AbortError')) + }) + }) + }) as any + + try { + const service = new DingTalkAiCardService(async () => 'token-1', 'robot-1') + const card = { + cardInstanceId: 'card-hung', + accessToken: 'token-1', + tokenExpireTime: Date.now() + 60_000, + inputingStarted: true, + } + + await expect(service.stream(card, 'Hello', false)).rejects.toThrow( + 'PUT /v1.0/card/streaming timed out after 20ms', + ) + } finally { + if (previousTimeout === undefined) { + delete process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS + } else { + process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS = previousTimeout + } + } + }) }) diff --git a/adapters/dingtalk/ai-card.ts b/adapters/dingtalk/ai-card.ts index 919b3962..2a9d3fb3 100644 --- a/adapters/dingtalk/ai-card.ts +++ b/adapters/dingtalk/ai-card.ts @@ -2,6 +2,7 @@ const DINGTALK_API = 'https://api.dingtalk.com' const AI_CARD_TEMPLATE_ID = '02fcf2f4-5e02-4a85-b672-46d1f715543e.schema' const CARD_API_MAX_QPS = 20 const QPS_BACKOFF_DURATION_MS = 2_000 +const DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS = 15_000 const AICardStatus = { INPUTING: '2', @@ -178,23 +179,44 @@ async function requestJson( token: string, body: Record, ): Promise { - const res = await fetch(`${DINGTALK_API}${path}`, { - method, - headers: { - 'Content-Type': 'application/json', - 'x-acs-dingtalk-access-token': token, - }, - body: JSON.stringify(body), - }) - if (!res.ok) { - const text = await res.text().catch(() => '') - const err = new Error(`${method} ${path} failed: ${res.status} ${text}`) - ;(err as any).status = res.status - ;(err as any).body = text + const controller = new AbortController() + const timeoutMs = getImCardRequestTimeoutMs() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const res = await fetch(`${DINGTALK_API}${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + 'x-acs-dingtalk-access-token': token, + }, + body: JSON.stringify(body), + signal: controller.signal, + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + const err = new Error(`${method} ${path} failed: ${res.status} ${text}`) + ;(err as any).status = res.status + ;(err as any).body = text + throw err + } + } catch (err) { + if ((err as Error)?.name === 'AbortError') { + throw new Error(`${method} ${path} timed out after ${timeoutMs}ms`) + } throw err + } finally { + clearTimeout(timer) } } +function getImCardRequestTimeoutMs(): number { + const raw = process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS + const parsed = raw ? Number(raw) : DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS + return Number.isFinite(parsed) && parsed > 0 + ? parsed + : DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS +} + async function withCardRateLimit(fn: () => Promise): Promise { await cardRateLimiter.waitForToken() try { diff --git a/adapters/feishu/__tests__/streaming-card.test.ts b/adapters/feishu/__tests__/streaming-card.test.ts index bf83a8f0..fbb090ae 100644 --- a/adapters/feishu/__tests__/streaming-card.test.ts +++ b/adapters/feishu/__tests__/streaming-card.test.ts @@ -535,6 +535,39 @@ describe('StreamingCard: 错误处理', () => { // 不走 patch expect(calls.some((c) => c.api === 'im.message.patch')).toBe(false) }) + + it('CardKit 中间帧请求挂住时不会阻塞 message_complete 收尾', async () => { + const previousTimeout = process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS + process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS = '20' + try { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_hung' } }, + 'im.message.create': { data: { message_id: 'om' } }, + 'cardElement.content': () => new Promise(() => {}), + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + sc.appendText('partial text') + await sleep(60) + + const completed = await Promise.race([ + sc.finalize().then(() => true), + sleep(250).then(() => false), + ]) + + expect(completed).toBe(true) + expect(sc._getPhase()).toBe('completed') + expect(calls.some((c) => c.api === 'cardkit.v1.card.settings')).toBe(true) + expect(calls.some((c) => c.api === 'cardkit.v1.card.update')).toBe(true) + } finally { + if (previousTimeout === undefined) { + delete process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS + } else { + process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS = previousTimeout + } + } + }) }) // --------------------------------------------------------------------------- diff --git a/adapters/feishu/cardkit.ts b/adapters/feishu/cardkit.ts index c331976c..4a343331 100644 --- a/adapters/feishu/cardkit.ts +++ b/adapters/feishu/cardkit.ts @@ -61,6 +61,37 @@ export class CardKitApiError extends Error { type LarkClient = Lark.Client +const DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS = 15_000 + +function getImCardRequestTimeoutMs(): number { + const raw = process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS + const parsed = raw ? Number(raw) : DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS + return Number.isFinite(parsed) && parsed > 0 + ? parsed + : DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS +} + +export async function withImCardRequestTimeout( + api: string, + request: () => Promise, +): Promise { + const timeoutMs = getImCardRequestTimeoutMs() + let timer: ReturnType | undefined + + try { + return await Promise.race([ + Promise.resolve().then(request), + new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error(`${api} timed out after ${timeoutMs}ms`)) + }, timeoutMs) + }), + ]) + } finally { + if (timer) clearTimeout(timer) + } +} + // --------------------------------------------------------------------------- // Response check // --------------------------------------------------------------------------- @@ -106,12 +137,14 @@ export async function createCardEntity( card: Record, ): Promise { // SDK 返回类型不完整,cast 到运行时实际结构 - const resp = (await client.cardkit.v1.card.create({ - data: { - type: 'card_json', - data: JSON.stringify(card), - }, - })) as unknown as CardKitResponse + const resp = (await withImCardRequestTimeout('card.create', () => + client.cardkit.v1.card.create({ + data: { + type: 'card_json', + data: JSON.stringify(card), + }, + }), + )) as unknown as CardKitResponse assertCardKitOk({ resp, @@ -163,10 +196,12 @@ export async function sendCardAsMessage( }) if (replyToMessageId) { - const resp = await client.im.message.reply({ - path: { message_id: replyToMessageId }, - data: { content, msg_type: 'interactive' }, - }) + const resp = await withImCardRequestTimeout('im.message.reply', () => + client.im.message.reply({ + path: { message_id: replyToMessageId }, + data: { content, msg_type: 'interactive' }, + }), + ) const messageId = resp.data?.message_id if (!messageId) { throw new CardKitApiError({ @@ -179,14 +214,16 @@ export async function sendCardAsMessage( return messageId } - const resp = await client.im.message.create({ - params: { receive_id_type: 'chat_id' }, - data: { - receive_id: chatId, - msg_type: 'interactive', - content, - }, - }) + const resp = await withImCardRequestTimeout('im.message.create', () => + client.im.message.create({ + params: { receive_id_type: 'chat_id' }, + data: { + receive_id: chatId, + msg_type: 'interactive', + content, + }, + }), + ) const messageId = resp.data?.message_id if (!messageId) { throw new CardKitApiError({ @@ -223,10 +260,12 @@ export async function streamCardContent( content: string, sequence: number, ): Promise { - const resp = (await client.cardkit.v1.cardElement.content({ - data: { content, sequence }, - path: { card_id: cardId, element_id: elementId }, - })) as unknown as CardKitResponse + const resp = (await withImCardRequestTimeout('cardElement.content', () => + client.cardkit.v1.cardElement.content({ + data: { content, sequence }, + path: { card_id: cardId, element_id: elementId }, + }), + )) as unknown as CardKitResponse assertCardKitOk({ resp, @@ -249,13 +288,15 @@ export async function setCardStreamingMode( streamingMode: boolean, sequence: number, ): Promise { - const resp = (await client.cardkit.v1.card.settings({ - data: { - settings: JSON.stringify({ streaming_mode: streamingMode }), - sequence, - }, - path: { card_id: cardId }, - })) as unknown as CardKitResponse + const resp = (await withImCardRequestTimeout('card.settings', () => + client.cardkit.v1.card.settings({ + data: { + settings: JSON.stringify({ streaming_mode: streamingMode }), + sequence, + }, + path: { card_id: cardId }, + }), + )) as unknown as CardKitResponse assertCardKitOk({ resp, @@ -278,13 +319,15 @@ export async function updateCardKitCard( card: Record, sequence: number, ): Promise { - const resp = (await client.cardkit.v1.card.update({ - data: { - card: { type: 'card_json', data: JSON.stringify(card) }, - sequence, - }, - path: { card_id: cardId }, - })) as unknown as CardKitResponse + const resp = (await withImCardRequestTimeout('card.update', () => + client.cardkit.v1.card.update({ + data: { + card: { type: 'card_json', data: JSON.stringify(card) }, + sequence, + }, + path: { card_id: cardId }, + }), + )) as unknown as CardKitResponse assertCardKitOk({ resp, diff --git a/adapters/feishu/streaming-card.ts b/adapters/feishu/streaming-card.ts index eadc8b94..233a4435 100644 --- a/adapters/feishu/streaming-card.ts +++ b/adapters/feishu/streaming-card.ts @@ -21,6 +21,7 @@ import { setCardStreamingMode, updateCardKitCard, STREAMING_ELEMENT_ID, + withImCardRequestTimeout, } from './cardkit.js' import { isCardRateLimitError, isCardTableLimitError } from './card-errors.js' import { optimizeMarkdownForFeishu, sanitizeTextForCard } from './markdown-style.js' @@ -211,14 +212,16 @@ export class StreamingCard { cardKitErr instanceof Error ? cardKitErr.message : cardKitErr, ) try { - const fallbackResp = await this.deps.larkClient.im.message.create({ - params: { receive_id_type: 'chat_id' }, - data: { - receive_id: this.deps.chatId, - msg_type: 'interactive', - content: JSON.stringify(buildRenderedCard(' ')), - }, - }) + const fallbackResp = await withImCardRequestTimeout('im.message.create', () => + this.deps.larkClient.im.message.create({ + params: { receive_id_type: 'chat_id' }, + data: { + receive_id: this.deps.chatId, + msg_type: 'interactive', + content: JSON.stringify(buildRenderedCard(' ')), + }, + }), + ) const mid = fallbackResp.data?.message_id if (!mid) { throw new Error('fallback im.message.create returned no message_id') @@ -343,16 +346,33 @@ export class StreamingCard { ) } else if (this.messageId) { // Patch fallback 路径: 全量替换 - await this.deps.larkClient.im.message.patch({ - path: { message_id: this.messageId }, - data: { content: JSON.stringify(buildRenderedCard(finalText)) }, - }) + await withImCardRequestTimeout('im.message.patch', () => + this.deps.larkClient.im.message.patch({ + path: { message_id: this.messageId! }, + data: { content: JSON.stringify(buildRenderedCard(finalText)) }, + }), + ) } } catch (err) { console.error( '[Feishu StreamingCard] finalize failed:', err instanceof Error ? err.message : err, ) + if (this.messageId) { + try { + await withImCardRequestTimeout('im.message.patch', () => + this.deps.larkClient.im.message.patch({ + path: { message_id: this.messageId! }, + data: { content: JSON.stringify(buildRenderedCard(finalText)) }, + }), + ) + } catch (fallbackErr) { + console.error( + '[Feishu StreamingCard] finalize fallback patch failed:', + fallbackErr instanceof Error ? fallbackErr.message : fallbackErr, + ) + } + } // 不抛出 —— 用户已经看到某种版本的内容,finalize 失败不是致命错误 } finally { this.phase = 'completed' @@ -395,10 +415,12 @@ export class StreamingCard { this.sequence, ) } else { - await this.deps.larkClient.im.message.patch({ - path: { message_id: this.messageId }, - data: { content: JSON.stringify(errCard) }, - }) + await withImCardRequestTimeout('im.message.patch', () => + this.deps.larkClient.im.message.patch({ + path: { message_id: this.messageId! }, + data: { content: JSON.stringify(errCard) }, + }), + ) } } catch (renderErr) { console.error( @@ -546,10 +568,12 @@ export class StreamingCard { } else { // Patch fallback 路径(CardKit 从未成功) try { - await this.deps.larkClient.im.message.patch({ - path: { message_id: this.messageId }, - data: { content: JSON.stringify(buildRenderedCard(finalText)) }, - }) + await withImCardRequestTimeout('im.message.patch', () => + this.deps.larkClient.im.message.patch({ + path: { message_id: this.messageId! }, + data: { content: JSON.stringify(buildRenderedCard(finalText)) }, + }), + ) this.lastFlushedText = finalText } catch (err) { if (isCardRateLimitError(err)) return