mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-25 16:44:36 +00:00
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:
parent
f69cc09e77
commit
9fb6e1cefc
2 changed files with 175 additions and 22 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue