forked from EduCraft/curriculum-project-hub
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:
+44
-11
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user