mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
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:
parent
08747bfdd2
commit
6031c2e2a5
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user