Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 99 additions & 1 deletion packages/channels/dingtalk/src/DingtalkAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -806,7 +806,95 @@ 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('sanitizes failed emotion response details before logging', async () => {
vi.useFakeTimers();
const channel = createChannel();
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
Expand All @@ -833,16 +921,26 @@ 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('');
const emotionCalls = fetchSpy.mock.calls.filter((call) =>
String(call[0]).startsWith(
'https://api.dingtalk.com/v1.0/robot/emotion/reply',
),
);
expect(emotionCalls).toHaveLength(3);
expect(stderr).toHaveBeenCalledOnce();
expect(logged).toContain('bad\\n[DingTalk:fake] forged');
expect(logged).not.toContain('bad\n');
} finally {
vi.useRealTimers();
stderr.mockRestore();
fetchSpy.mockRestore();
}
Expand Down
54 changes: 34 additions & 20 deletions packages/channels/dingtalk/src/DingtalkAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: please add a focused 429 retry case. The new predicate has a distinct status === 429 branch, while the current recovery test only exercises 500 responses, so a future regression could silently drop the rate-limit behavior this PR promises. A short 429 → 200 case should be enough.

if (isTransient && attempt < EMOTION_MAX_ATTEMPTS - 1) {
await resp.body?.cancel();
Comment on lines +705 to +707

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] 429 responses are retried without honouring the server's Retry-After header. — Failure scenario: DingTalk returns 429 with Retry-After: 10. The code retries in 250ms, gets another 429, retries in 500ms, gets a third 429, and gives up — all within 750ms. Three rapid-fire requests in violation of the server's rate limit may escalate enforcement (temporary IP/token ban).

Suggested change
const isTransient = resp.status === 429 || resp.status >= 500;
if (isTransient && attempt < EMOTION_MAX_ATTEMPTS - 1) {
await resp.body?.cancel();
const isTransient = resp.status === 429 || resp.status >= 500;
if (isTransient && attempt < EMOTION_MAX_ATTEMPTS - 1) {
await resp.body?.cancel();
let delay = EMOTION_RETRY_BASE_DELAY_MS * 2 ** attempt;
if (resp.status === 429) {
const retryAfter = resp.headers.get('Retry-After');
if (retryAfter) {
const parsed = Number(retryAfter);
if (Number.isFinite(parsed) && parsed > 0) {
delay = Math.min(parsed * 1000, 5000);
}
}
}
await new Promise((resolve) => setTimeout(resolve, delay));

— qwen3.7-max via Qwen Code /review

await new Promise((resolve) =>
setTimeout(resolve, EMOTION_RETRY_BASE_DELAY_MS * 2 ** attempt),
);
Comment on lines +706 to +710

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Intermediate retry attempts produce no log output — only the final failure is logged. — Concrete cost: at 3 AM, an oncall engineer sees emotion/reply failed after 3/3 attempts: 500 {"errmsg":"system busy"} but cannot tell whether all 3 attempts hit 500 or whether the error changed between attempts. A one-line log per retry (with attempt number and status) makes this trivially diagnosable.

Suggested change
if (isTransient && attempt < EMOTION_MAX_ATTEMPTS - 1) {
await resp.body?.cancel();
await new Promise((resolve) =>
setTimeout(resolve, EMOTION_RETRY_BASE_DELAY_MS * 2 ** attempt),
);
if (isTransient && attempt < EMOTION_MAX_ATTEMPTS - 1) {
await resp.body?.cancel();
process.stderr.write(
`[DingTalk:${this.name}] emotion/${endpoint} attempt ${attempt + 1}/${EMOTION_MAX_ATTEMPTS} failed (${resp.status}), retrying\n`,
);
await new Promise((resolve) =>
setTimeout(resolve, EMOTION_RETRY_BASE_DELAY_MS * 2 ** attempt),
);

— qwen3.7-max via Qwen Code /review

continue;
}

const detail = sanitizeLogText(await resp.text().catch(() => ''), 500);
process.stderr.write(
`[DingTalk:${this.name}] emotion/${endpoint} failed: ${resp.status} ${detail}\n`,
);
return;
}
} catch {
// best-effort, don't break message flow
Expand Down
Loading