Prevent IM stream cards from blocking completion

Feishu CardKit and DingTalk AI Card updates run inside the serialized adapter message path. A hung platform update can prevent the later message_complete event from being handled, leaving mobile IM users stuck on a partial streaming card.

This bounds platform card API calls and gives Feishu a final patch fallback when CardKit finalization fails, so a bad intermediate frame cannot permanently block the chat turn.

Constraint: Feishu and DingTalk card APIs are external network calls without reliable SDK-level deadline guarantees

Rejected: Disable streaming cards entirely | loses the live IM experience for normal fast responses

Confidence: high

Scope-risk: narrow

Directive: Keep IM card platform calls bounded because adapter server messages are serialized per chat

Tested: bun test adapters/feishu/__tests__/streaming-card.test.ts

Tested: bun test adapters/dingtalk/__tests__/ai-card.test.ts

Tested: bun run check:adapters

Tested: bunx tsc -p adapters/tsconfig.json --noEmit

Not-tested: Full bun run verify was interrupted during the coverage lane after dependency setup
This commit is contained in:
程序员阿江(Relakkes) 2026-05-13 12:44:52 +08:00
parent 08747bfdd2
commit 6031c2e2a5
5 changed files with 228 additions and 69 deletions

View File

@ -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<Response>((_, 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
}
}
})
})

View File

@ -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<string, unknown>,
): Promise<void> {
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<void>): Promise<void> {
await cardRateLimiter.waitForToken()
try {

View File

@ -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
}
}
})
})
// ---------------------------------------------------------------------------

View File

@ -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<T>(
api: string,
request: () => Promise<T>,
): Promise<T> {
const timeoutMs = getImCardRequestTimeoutMs()
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
Promise.resolve().then(request),
new Promise<never>((_, 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<string, unknown>,
): Promise<string> {
// 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<void> {
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<void> {
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<string, unknown>,
sequence: number,
): Promise<void> {
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,

View File

@ -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