Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
141 changes: 140 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,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 }),
);
Comment on lines +829 to +832

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] The isTransient predicate has a distinct resp.status === 429 branch, but all three retry tests exercise only the >= 500 path with status 500. A future refactor that simplifies the condition to resp.status >= 500 would pass every existing test while silently dropping rate-limit retry support. — Failure scenario: 429 responses stop retrying, DingTalk rate-limit errors immediately log failure instead of backing off.

Consider adding a test that returns 429 on the first attempt and 200 on the second, asserting emotionAttempts === 2, or parameterize the existing retry test over [429, 500, 502].

— qwen3.7-max via Qwen Code /review

});
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')
Expand All @@ -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();
}
Expand Down
56 changes: 35 additions & 21 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;
}
Comment on lines +705 to +712

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] If a fetch() call inside the retry loop throws a network error (ECONNRESET, DNS failure), the outer catch {} swallows it silently — but the retry loop has now introduced a multi-fetch sequence where a prior transient 500 that triggered the retry is also never logged. — Failure scenario: first call returns 500 → retry sleeps 250ms → second fetch() throws → outer catch swallows → neither the 500 nor the network error appears in any log, making it harder to diagnose why a reaction emoji was never attached.

Consider wrapping the per-iteration fetch in its own try-catch that logs the error on the last attempt, so the outer catch only fires for truly unexpected errors.

— qwen3.7-max via Qwen Code /review


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`,
);
Comment on lines 714 to 717

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] The terminal error log is identical whether the failure happened on the first attempt (non-transient 4xx) or after exhausting all 3 retries (persistent 500). An oncall engineer seeing [DingTalk:bot] emotion/reply failed: 500 {...} cannot tell from the log alone whether retries ran. — Failure scenario: sustained DingTalk outage → every log line looks like a single failure → misdiagnosis of whether the retry mechanism is active.

Suggested change
const detail = sanitizeLogText(await resp.text().catch(() => ''), 500);
process.stderr.write(
`[DingTalk:${this.name}] emotion/${endpoint} failed: ${resp.status} ${detail}\n`,
);
const detail = sanitizeLogText(await resp.text().catch(() => ''), 500);
process.stderr.write(
`[DingTalk:${this.name}] emotion/${endpoint} failed after ${attempt + 1}/${EMOTION_MAX_ATTEMPTS} attempts: ${resp.status} ${detail}\n`,
);

— qwen3.7-max via Qwen Code /review

return;
}
} catch {
// best-effort, don't break message flow
Expand Down
Loading