feat: add retry with exponential backoff for file send/download

- withRetry helper: configurable maxAttempts, baseDelayMs, shouldRetry
- sendFile: upload and message send wrapped with 3 retries, 1s/2s/4s backoff
- downloadMessageFile: wrapped with 3 retries
- Add unit tests (6) for retry behavior and sendFile retry integration
This commit is contained in:
2026-07-08 17:15:30 +08:00
parent 294d51dbfe
commit 2736006262
2 changed files with 207 additions and 11 deletions
+44 -11
View File
@@ -27,6 +27,33 @@ export interface OutboundPayload {
readonly content: string;
}
export async function withRetry<T>(
fn: () => Promise<T>,
options: {
maxAttempts?: number;
baseDelayMs?: number;
shouldRetry?: (error: unknown) => boolean;
} = {},
): Promise<T> {
const maxAttempts = Math.max(1, options.maxAttempts ?? 3);
const baseDelayMs = options.baseDelayMs ?? 1000;
const shouldRetry = options.shouldRetry ?? (() => true);
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
return await fn();
} catch (error) {
if (attempt === maxAttempts || !shouldRetry(error)) {
throw error;
}
const delay = baseDelayMs * 2 ** (attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw new Error("withRetry reached an unreachable state");
}
/** Raw `im.message.receive_v1` event payload. */
export interface MessageReceiveEvent {
/** Feishu's SDK dispatcher flattens v2 headers onto the top-level payload. */
@@ -340,12 +367,14 @@ export async function downloadMessageFile(
): Promise<void> {
await mkdir(dirname(savePath), { recursive: true });
try {
const res = await rt.client.request({
method: "GET",
url: `/open-apis/im/v1/messages/${messageId}/resources/${fileKey}`,
params: { type: "file" },
responseType: "arraybuffer",
}) as { data: Buffer };
const res = await withRetry(async () => {
return rt.client.request({
method: "GET",
url: `/open-apis/im/v1/messages/${messageId}/resources/${fileKey}`,
params: { type: "file" },
responseType: "arraybuffer",
}) as Promise<{ data: Buffer }>;
});
await writeFile(savePath, res.data);
} catch (e) {
rt.logger.warn({ messageId, fileKey, err: e instanceof Error ? e.message : String(e) }, "downloadMessageFile failed");
@@ -364,16 +393,20 @@ export async function sendFile(rt: FeishuRuntime, chatId: string, filePath: stri
const buf = await readFile(filePath);
const ext = fileName.split(".").pop() ?? "";
const fileType = ext === "pdf" ? "pdf" : ext === "doc" ? "doc" : ext === "xls" ? "xls" : ext === "ppt" ? "ppt" : "stream";
const uploadRes = await client.im.v1.file.create({ data: { file_type: fileType, file_name: fileName, file: buf } });
const uploadRes = await withRetry(async () =>
client.im.v1.file.create({ data: { file_type: fileType, file_name: fileName, file: buf } }),
);
const fileKey = fileKeyFromResponse(uploadRes);
if (fileKey === undefined) {
rt.logger.warn({ chatId, filePath }, "sendFile failed: upload response missing file_key");
return null;
}
const msgRes = await client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: { receive_id: chatId, msg_type: "file", content: JSON.stringify({ file_key: fileKey }) },
});
const msgRes = await withRetry(async () =>
client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: { receive_id: chatId, msg_type: "file", content: JSON.stringify({ file_key: fileKey }) },
}),
);
const messageId = messageIdFromResponse(msgRes);
if (messageId === null) {
rt.logger.warn({ chatId, filePath }, "sendFile failed: message response missing message_id");