mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +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[0]!.body.isFinalize).toBe(true)
|
||||||
expect(calls[1]!.body.cardData.cardParamMap.flowStatus).toBe('3')
|
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 AI_CARD_TEMPLATE_ID = '02fcf2f4-5e02-4a85-b672-46d1f715543e.schema'
|
||||||
const CARD_API_MAX_QPS = 20
|
const CARD_API_MAX_QPS = 20
|
||||||
const QPS_BACKOFF_DURATION_MS = 2_000
|
const QPS_BACKOFF_DURATION_MS = 2_000
|
||||||
|
const DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS = 15_000
|
||||||
|
|
||||||
const AICardStatus = {
|
const AICardStatus = {
|
||||||
INPUTING: '2',
|
INPUTING: '2',
|
||||||
@ -178,23 +179,44 @@ async function requestJson(
|
|||||||
token: string,
|
token: string,
|
||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const res = await fetch(`${DINGTALK_API}${path}`, {
|
const controller = new AbortController()
|
||||||
method,
|
const timeoutMs = getImCardRequestTimeoutMs()
|
||||||
headers: {
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||||
'Content-Type': 'application/json',
|
try {
|
||||||
'x-acs-dingtalk-access-token': token,
|
const res = await fetch(`${DINGTALK_API}${path}`, {
|
||||||
},
|
method,
|
||||||
body: JSON.stringify(body),
|
headers: {
|
||||||
})
|
'Content-Type': 'application/json',
|
||||||
if (!res.ok) {
|
'x-acs-dingtalk-access-token': token,
|
||||||
const text = await res.text().catch(() => '')
|
},
|
||||||
const err = new Error(`${method} ${path} failed: ${res.status} ${text}`)
|
body: JSON.stringify(body),
|
||||||
;(err as any).status = res.status
|
signal: controller.signal,
|
||||||
;(err as any).body = text
|
})
|
||||||
|
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
|
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> {
|
async function withCardRateLimit(fn: () => Promise<void>): Promise<void> {
|
||||||
await cardRateLimiter.waitForToken()
|
await cardRateLimiter.waitForToken()
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -535,6 +535,39 @@ describe('StreamingCard: 错误处理', () => {
|
|||||||
// 不走 patch
|
// 不走 patch
|
||||||
expect(calls.some((c) => c.api === 'im.message.patch')).toBe(false)
|
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
|
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
|
// Response check
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -106,12 +137,14 @@ export async function createCardEntity(
|
|||||||
card: Record<string, unknown>,
|
card: Record<string, unknown>,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
// SDK 返回类型不完整,cast 到运行时实际结构
|
// SDK 返回类型不完整,cast 到运行时实际结构
|
||||||
const resp = (await client.cardkit.v1.card.create({
|
const resp = (await withImCardRequestTimeout('card.create', () =>
|
||||||
data: {
|
client.cardkit.v1.card.create({
|
||||||
type: 'card_json',
|
data: {
|
||||||
data: JSON.stringify(card),
|
type: 'card_json',
|
||||||
},
|
data: JSON.stringify(card),
|
||||||
})) as unknown as CardKitResponse
|
},
|
||||||
|
}),
|
||||||
|
)) as unknown as CardKitResponse
|
||||||
|
|
||||||
assertCardKitOk({
|
assertCardKitOk({
|
||||||
resp,
|
resp,
|
||||||
@ -163,10 +196,12 @@ export async function sendCardAsMessage(
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (replyToMessageId) {
|
if (replyToMessageId) {
|
||||||
const resp = await client.im.message.reply({
|
const resp = await withImCardRequestTimeout('im.message.reply', () =>
|
||||||
path: { message_id: replyToMessageId },
|
client.im.message.reply({
|
||||||
data: { content, msg_type: 'interactive' },
|
path: { message_id: replyToMessageId },
|
||||||
})
|
data: { content, msg_type: 'interactive' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
const messageId = resp.data?.message_id
|
const messageId = resp.data?.message_id
|
||||||
if (!messageId) {
|
if (!messageId) {
|
||||||
throw new CardKitApiError({
|
throw new CardKitApiError({
|
||||||
@ -179,14 +214,16 @@ export async function sendCardAsMessage(
|
|||||||
return messageId
|
return messageId
|
||||||
}
|
}
|
||||||
|
|
||||||
const resp = await client.im.message.create({
|
const resp = await withImCardRequestTimeout('im.message.create', () =>
|
||||||
params: { receive_id_type: 'chat_id' },
|
client.im.message.create({
|
||||||
data: {
|
params: { receive_id_type: 'chat_id' },
|
||||||
receive_id: chatId,
|
data: {
|
||||||
msg_type: 'interactive',
|
receive_id: chatId,
|
||||||
content,
|
msg_type: 'interactive',
|
||||||
},
|
content,
|
||||||
})
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
const messageId = resp.data?.message_id
|
const messageId = resp.data?.message_id
|
||||||
if (!messageId) {
|
if (!messageId) {
|
||||||
throw new CardKitApiError({
|
throw new CardKitApiError({
|
||||||
@ -223,10 +260,12 @@ export async function streamCardContent(
|
|||||||
content: string,
|
content: string,
|
||||||
sequence: number,
|
sequence: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const resp = (await client.cardkit.v1.cardElement.content({
|
const resp = (await withImCardRequestTimeout('cardElement.content', () =>
|
||||||
data: { content, sequence },
|
client.cardkit.v1.cardElement.content({
|
||||||
path: { card_id: cardId, element_id: elementId },
|
data: { content, sequence },
|
||||||
})) as unknown as CardKitResponse
|
path: { card_id: cardId, element_id: elementId },
|
||||||
|
}),
|
||||||
|
)) as unknown as CardKitResponse
|
||||||
|
|
||||||
assertCardKitOk({
|
assertCardKitOk({
|
||||||
resp,
|
resp,
|
||||||
@ -249,13 +288,15 @@ export async function setCardStreamingMode(
|
|||||||
streamingMode: boolean,
|
streamingMode: boolean,
|
||||||
sequence: number,
|
sequence: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const resp = (await client.cardkit.v1.card.settings({
|
const resp = (await withImCardRequestTimeout('card.settings', () =>
|
||||||
data: {
|
client.cardkit.v1.card.settings({
|
||||||
settings: JSON.stringify({ streaming_mode: streamingMode }),
|
data: {
|
||||||
sequence,
|
settings: JSON.stringify({ streaming_mode: streamingMode }),
|
||||||
},
|
sequence,
|
||||||
path: { card_id: cardId },
|
},
|
||||||
})) as unknown as CardKitResponse
|
path: { card_id: cardId },
|
||||||
|
}),
|
||||||
|
)) as unknown as CardKitResponse
|
||||||
|
|
||||||
assertCardKitOk({
|
assertCardKitOk({
|
||||||
resp,
|
resp,
|
||||||
@ -278,13 +319,15 @@ export async function updateCardKitCard(
|
|||||||
card: Record<string, unknown>,
|
card: Record<string, unknown>,
|
||||||
sequence: number,
|
sequence: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const resp = (await client.cardkit.v1.card.update({
|
const resp = (await withImCardRequestTimeout('card.update', () =>
|
||||||
data: {
|
client.cardkit.v1.card.update({
|
||||||
card: { type: 'card_json', data: JSON.stringify(card) },
|
data: {
|
||||||
sequence,
|
card: { type: 'card_json', data: JSON.stringify(card) },
|
||||||
},
|
sequence,
|
||||||
path: { card_id: cardId },
|
},
|
||||||
})) as unknown as CardKitResponse
|
path: { card_id: cardId },
|
||||||
|
}),
|
||||||
|
)) as unknown as CardKitResponse
|
||||||
|
|
||||||
assertCardKitOk({
|
assertCardKitOk({
|
||||||
resp,
|
resp,
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import {
|
|||||||
setCardStreamingMode,
|
setCardStreamingMode,
|
||||||
updateCardKitCard,
|
updateCardKitCard,
|
||||||
STREAMING_ELEMENT_ID,
|
STREAMING_ELEMENT_ID,
|
||||||
|
withImCardRequestTimeout,
|
||||||
} from './cardkit.js'
|
} from './cardkit.js'
|
||||||
import { isCardRateLimitError, isCardTableLimitError } from './card-errors.js'
|
import { isCardRateLimitError, isCardTableLimitError } from './card-errors.js'
|
||||||
import { optimizeMarkdownForFeishu, sanitizeTextForCard } from './markdown-style.js'
|
import { optimizeMarkdownForFeishu, sanitizeTextForCard } from './markdown-style.js'
|
||||||
@ -211,14 +212,16 @@ export class StreamingCard {
|
|||||||
cardKitErr instanceof Error ? cardKitErr.message : cardKitErr,
|
cardKitErr instanceof Error ? cardKitErr.message : cardKitErr,
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
const fallbackResp = await this.deps.larkClient.im.message.create({
|
const fallbackResp = await withImCardRequestTimeout('im.message.create', () =>
|
||||||
params: { receive_id_type: 'chat_id' },
|
this.deps.larkClient.im.message.create({
|
||||||
data: {
|
params: { receive_id_type: 'chat_id' },
|
||||||
receive_id: this.deps.chatId,
|
data: {
|
||||||
msg_type: 'interactive',
|
receive_id: this.deps.chatId,
|
||||||
content: JSON.stringify(buildRenderedCard(' ')),
|
msg_type: 'interactive',
|
||||||
},
|
content: JSON.stringify(buildRenderedCard(' ')),
|
||||||
})
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
const mid = fallbackResp.data?.message_id
|
const mid = fallbackResp.data?.message_id
|
||||||
if (!mid) {
|
if (!mid) {
|
||||||
throw new Error('fallback im.message.create returned no message_id')
|
throw new Error('fallback im.message.create returned no message_id')
|
||||||
@ -343,16 +346,33 @@ export class StreamingCard {
|
|||||||
)
|
)
|
||||||
} else if (this.messageId) {
|
} else if (this.messageId) {
|
||||||
// Patch fallback 路径: 全量替换
|
// Patch fallback 路径: 全量替换
|
||||||
await this.deps.larkClient.im.message.patch({
|
await withImCardRequestTimeout('im.message.patch', () =>
|
||||||
path: { message_id: this.messageId },
|
this.deps.larkClient.im.message.patch({
|
||||||
data: { content: JSON.stringify(buildRenderedCard(finalText)) },
|
path: { message_id: this.messageId! },
|
||||||
})
|
data: { content: JSON.stringify(buildRenderedCard(finalText)) },
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
'[Feishu StreamingCard] finalize failed:',
|
'[Feishu StreamingCard] finalize failed:',
|
||||||
err instanceof Error ? err.message : err,
|
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 失败不是致命错误
|
// 不抛出 —— 用户已经看到某种版本的内容,finalize 失败不是致命错误
|
||||||
} finally {
|
} finally {
|
||||||
this.phase = 'completed'
|
this.phase = 'completed'
|
||||||
@ -395,10 +415,12 @@ export class StreamingCard {
|
|||||||
this.sequence,
|
this.sequence,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
await this.deps.larkClient.im.message.patch({
|
await withImCardRequestTimeout('im.message.patch', () =>
|
||||||
path: { message_id: this.messageId },
|
this.deps.larkClient.im.message.patch({
|
||||||
data: { content: JSON.stringify(errCard) },
|
path: { message_id: this.messageId! },
|
||||||
})
|
data: { content: JSON.stringify(errCard) },
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (renderErr) {
|
} catch (renderErr) {
|
||||||
console.error(
|
console.error(
|
||||||
@ -546,10 +568,12 @@ export class StreamingCard {
|
|||||||
} else {
|
} else {
|
||||||
// Patch fallback 路径(CardKit 从未成功)
|
// Patch fallback 路径(CardKit 从未成功)
|
||||||
try {
|
try {
|
||||||
await this.deps.larkClient.im.message.patch({
|
await withImCardRequestTimeout('im.message.patch', () =>
|
||||||
path: { message_id: this.messageId },
|
this.deps.larkClient.im.message.patch({
|
||||||
data: { content: JSON.stringify(buildRenderedCard(finalText)) },
|
path: { message_id: this.messageId! },
|
||||||
})
|
data: { content: JSON.stringify(buildRenderedCard(finalText)) },
|
||||||
|
}),
|
||||||
|
)
|
||||||
this.lastFlushedText = finalText
|
this.lastFlushedText = finalText
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isCardRateLimitError(err)) return
|
if (isCardRateLimitError(err)) return
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user