fix(dingtalk): retry transient emotion failures (#7329)

* 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 <qwen-code-dev-bot@users.noreply.github.com>
This commit is contained in:
qwen-code-dev-bot 2026-07-21 02:14:11 +08:00 committed by GitHub
parent f69cc09e77
commit 9fb6e1cefc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 175 additions and 22 deletions

View file

@ -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<void>;
}
).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<void>;
}
).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<void>;
}
).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<void>;
}
).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();
}

View file

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