From 9fb6e1cefcc8ef4e532d206df4964c458b942cf5 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Tue, 21 Jul 2026 02:14:11 +0800 Subject: [PATCH] fix(dingtalk): retry transient emotion failures (#7329) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dingtalk): retry transient emotion failures * fix(dingtalk): address review — add 429 retry test, improve error log (#7329) --------- Co-authored-by: qwen-code-dev-bot --- .../dingtalk/src/DingtalkAdapter.test.ts | 141 +++++++++++++++++- .../channels/dingtalk/src/DingtalkAdapter.ts | 56 ++++--- 2 files changed, 175 insertions(+), 22 deletions(-) diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.test.ts b/packages/channels/dingtalk/src/DingtalkAdapter.test.ts index d1111323ee..49eade9cb3 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.test.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.test.ts @@ -806,7 +806,142 @@ describe('DingtalkChannel prompt reactions', () => { } }); + it('retries transient emotion failures before succeeding', async () => { + vi.useFakeTimers(); + const channel = createChannel(); + let emotionAttempts = 0; + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockImplementation((input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith('https://oapi.dingtalk.com/gettoken')) { + return Promise.resolve( + new Response( + JSON.stringify({ + errcode: 0, + access_token: 'proactive-token', + expires_in: 7200, + }), + { status: 200 }, + ), + ); + } + emotionAttempts++; + return Promise.resolve( + new Response('{}', { status: emotionAttempts < 3 ? 500 : 200 }), + ); + }); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + + try { + const request = ( + channel as unknown as { + attachReaction(msgId: string, conversationId: string): Promise; + } + ).attachReaction('msg-1', 'cid-123'); + await vi.runAllTimersAsync(); + await request; + + expect(emotionAttempts).toBe(3); + expect(stderr).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + stderr.mockRestore(); + fetchSpy.mockRestore(); + } + }); + + it('does not retry non-transient emotion failures', async () => { + const channel = createChannel(); + let emotionAttempts = 0; + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockImplementation((input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith('https://oapi.dingtalk.com/gettoken')) { + return Promise.resolve( + new Response( + JSON.stringify({ + errcode: 0, + access_token: 'proactive-token', + expires_in: 7200, + }), + { status: 200 }, + ), + ); + } + emotionAttempts++; + return Promise.resolve(new Response('{}', { status: 400 })); + }); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + + try { + await ( + channel as unknown as { + attachReaction(msgId: string, conversationId: string): Promise; + } + ).attachReaction('msg-1', 'cid-123'); + + expect(emotionAttempts).toBe(1); + } finally { + stderr.mockRestore(); + fetchSpy.mockRestore(); + } + }); + + it('retries 429 rate-limit responses before succeeding', async () => { + vi.useFakeTimers(); + const channel = createChannel(); + let emotionAttempts = 0; + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockImplementation((input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith('https://oapi.dingtalk.com/gettoken')) { + return Promise.resolve( + new Response( + JSON.stringify({ + errcode: 0, + access_token: 'proactive-token', + expires_in: 7200, + }), + { status: 200 }, + ), + ); + } + emotionAttempts++; + return Promise.resolve( + new Response('{}', { status: emotionAttempts < 2 ? 429 : 200 }), + ); + }); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + + try { + const request = ( + channel as unknown as { + attachReaction(msgId: string, conversationId: string): Promise; + } + ).attachReaction('msg-1', 'cid-123'); + await vi.runAllTimersAsync(); + await request; + + expect(emotionAttempts).toBe(2); + expect(stderr).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + stderr.mockRestore(); + fetchSpy.mockRestore(); + } + }); + it('sanitizes failed emotion response details before logging', async () => { + vi.useFakeTimers(); const channel = createChannel(); const fetchSpy = vi .spyOn(globalThis, 'fetch') @@ -833,16 +968,20 @@ describe('DingtalkChannel prompt reactions', () => { .mockImplementation(() => true); try { - await ( + const request = ( channel as unknown as { attachReaction(msgId: string, conversationId: string): Promise; } ).attachReaction('msg-1', 'cid-123'); + await vi.runAllTimersAsync(); + await request; const logged = stderr.mock.calls.map((call) => String(call[0])).join(''); + expect(stderr).toHaveBeenCalledOnce(); expect(logged).toContain('bad\\n[DingTalk:fake] forged'); expect(logged).not.toContain('bad\n'); } finally { + vi.useRealTimers(); stderr.mockRestore(); fetchSpy.mockRestore(); } diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.ts b/packages/channels/dingtalk/src/DingtalkAdapter.ts index 0ff623767d..c529e137db 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.ts @@ -88,6 +88,8 @@ const ACK_REACTION_NAME = '👀'; const ACK_EMOTION_ID = '2659900'; const ACK_EMOTION_BG_ID = 'im_bg_1'; const EMOTION_API = 'https://api.dingtalk.com/v1.0/robot/emotion'; +const EMOTION_MAX_ATTEMPTS = 3; +const EMOTION_RETRY_BASE_DELAY_MS = 250; const GROUP_MSG_API = 'https://api.dingtalk.com/v1.0/robot/groupMessages/send'; const DIRECT_MSG_API = 'https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend'; @@ -677,31 +679,43 @@ export class DingtalkChannel extends ChannelBase { ? await this.getProactiveToken() : this.getAccessToken(); if (!token) return; - const resp = await fetch(`${EMOTION_API}/${endpoint}`, { - method: 'POST', - headers: { - 'x-acs-dingtalk-access-token': token, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - robotCode, - openMsgId: msgId, - openConversationId: conversationId, - emotionType: 2, - emotionName: ACK_REACTION_NAME, - textEmotion: { - emotionId: ACK_EMOTION_ID, - emotionName: ACK_REACTION_NAME, - text: ACK_REACTION_NAME, - backgroundId: ACK_EMOTION_BG_ID, + for (let attempt = 0; attempt < EMOTION_MAX_ATTEMPTS; attempt++) { + const resp = await fetch(`${EMOTION_API}/${endpoint}`, { + method: 'POST', + headers: { + 'x-acs-dingtalk-access-token': token, + 'Content-Type': 'application/json', }, - }), - }); - if (!resp.ok) { + body: JSON.stringify({ + robotCode, + openMsgId: msgId, + openConversationId: conversationId, + emotionType: 2, + emotionName: ACK_REACTION_NAME, + textEmotion: { + emotionId: ACK_EMOTION_ID, + emotionName: ACK_REACTION_NAME, + text: ACK_REACTION_NAME, + backgroundId: ACK_EMOTION_BG_ID, + }, + }), + }); + if (resp.ok) return; + + const isTransient = resp.status === 429 || resp.status >= 500; + if (isTransient && attempt < EMOTION_MAX_ATTEMPTS - 1) { + await resp.body?.cancel(); + await new Promise((resolve) => + setTimeout(resolve, EMOTION_RETRY_BASE_DELAY_MS * 2 ** attempt), + ); + continue; + } + const detail = sanitizeLogText(await resp.text().catch(() => ''), 500); process.stderr.write( - `[DingTalk:${this.name}] emotion/${endpoint} failed: ${resp.status} ${detail}\n`, + `[DingTalk:${this.name}] emotion/${endpoint} failed after ${attempt + 1}/${EMOTION_MAX_ATTEMPTS} attempts: ${resp.status} ${detail}\n`, ); + return; } } catch { // best-effort, don't break message flow