fix(telegram): add retry logic to media file download

This commit is contained in:
Jerry-Xin 2026-03-14 18:52:25 +08:00
parent 8a05c05596
commit e2c4993be7
2 changed files with 104 additions and 14 deletions

View File

@ -200,15 +200,71 @@ describe("resolveMedia getFile retry", () => {
},
);
it("does not catch errors from fetchRemoteMedia (only getFile is retried)", async () => {
it("retries fetchRemoteMedia on transient network failure and succeeds", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "voice/file_0.oga" });
fetchRemoteMedia.mockRejectedValueOnce(new Error("download failed"));
fetchRemoteMedia
.mockRejectedValueOnce(new MockMediaFetchError("fetch_failed", "Network request failed"))
.mockResolvedValueOnce({
buffer: Buffer.from("audio"),
contentType: "audio/ogg",
fileName: "file_0.oga",
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/file_0.oga",
contentType: "audio/ogg",
});
const promise = resolveMedia(makeCtx("voice", getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
await flushRetryTimers();
const result = await promise;
expect(getFile).toHaveBeenCalledTimes(1);
expect(fetchRemoteMedia).toHaveBeenCalledTimes(2);
expect(result).toEqual(
expect.objectContaining({ path: "/tmp/file_0.oga", placeholder: "<media:audio>" }),
);
});
it("does not retry fetchRemoteMedia on permanent http_error", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "voice/file_0.oga" });
fetchRemoteMedia.mockRejectedValueOnce(
new MockMediaFetchError("http_error", "HTTP 404 Not Found"),
);
await expect(
resolveMedia(makeCtx("voice", getFile), MAX_MEDIA_BYTES, BOT_TOKEN),
).rejects.toThrow("download failed");
).rejects.toThrow("HTTP 404 Not Found");
expect(getFile).toHaveBeenCalledTimes(1);
expect(fetchRemoteMedia).toHaveBeenCalledTimes(1);
});
it("does not retry fetchRemoteMedia on max_bytes policy violation", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "video/large.mp4" });
fetchRemoteMedia.mockRejectedValueOnce(
new MockMediaFetchError("max_bytes", "File exceeds maximum size"),
);
await expect(
resolveMedia(makeCtx("video", getFile), MAX_MEDIA_BYTES, BOT_TOKEN),
).rejects.toThrow("File exceeds maximum size");
expect(getFile).toHaveBeenCalledTimes(1);
expect(fetchRemoteMedia).toHaveBeenCalledTimes(1);
});
it("throws after fetchRemoteMedia exhausts retries on persistent network failure", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "voice/file_0.oga" });
fetchRemoteMedia.mockRejectedValue(
new MockMediaFetchError("fetch_failed", "Network request failed"),
);
const promise = resolveMedia(makeCtx("voice", getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
await flushRetryTimers();
await expect(promise).rejects.toThrow("Network request failed");
expect(getFile).toHaveBeenCalledTimes(1);
expect(fetchRemoteMedia).toHaveBeenCalledTimes(3);
});
it("does not retry 'file is too big' error (400 Bad Request) and returns null", async () => {

View File

@ -2,7 +2,7 @@ import path from "node:path";
import { GrammyError } from "grammy";
import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime";
import { retryAsync } from "openclaw/plugin-sdk/infra-runtime";
import { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime";
import { fetchRemoteMedia, MediaFetchError } from "openclaw/plugin-sdk/media-runtime";
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime";
import { logVerbose, warn } from "openclaw/plugin-sdk/runtime-env";
import {
@ -60,6 +60,27 @@ function isRetryableGetFileError(err: unknown): boolean {
return true;
}
/**
* Returns true if the download error is a transient network error that should be retried.
* Returns false for permanent errors like HTTP 4xx, max_bytes policy violations.
*/
function isRetryableDownloadError(err: unknown): boolean {
if (err instanceof MediaFetchError) {
// Only retry transient fetch failures (network issues, timeouts, connection drops).
// Do not retry HTTP errors (4xx, 5xx) or policy violations (max_bytes).
return err.code === "fetch_failed";
}
// For non-MediaFetchError, check if it looks like a transient network error.
const msg = formatErrorMessage(err).toLowerCase();
return (
msg.includes("fetch failed") ||
msg.includes("network") ||
msg.includes("econnreset") ||
msg.includes("etimedout") ||
msg.includes("econnrefused")
);
}
function resolveMediaFileRef(msg: TelegramContext["message"]) {
return (
msg.photo?.[msg.photo.length - 1] ??
@ -149,16 +170,29 @@ async function downloadAndSaveTelegramFile(params: {
}
const apiBase = resolveTelegramApiBase(params.apiRoot);
const url = `${apiBase}/file/bot${params.token}/${params.filePath}`;
const fetched = await fetchRemoteMedia({
url,
fetchImpl: params.transport.sourceFetch,
dispatcherAttempts: params.transport.dispatcherAttempts,
shouldRetryFetchError: shouldRetryTelegramTransportFallback,
filePathHint: params.filePath,
maxBytes: params.maxBytes,
readIdleTimeoutMs: TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS,
ssrfPolicy: buildTelegramMediaSsrfPolicy(params.apiRoot),
});
const fetched = await retryAsync(
() =>
fetchRemoteMedia({
url,
fetchImpl: params.transport.sourceFetch,
dispatcherAttempts: params.transport.dispatcherAttempts,
shouldRetryFetchError: shouldRetryTelegramTransportFallback,
filePathHint: params.filePath,
maxBytes: params.maxBytes,
readIdleTimeoutMs: TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS,
ssrfPolicy: buildTelegramMediaSsrfPolicy(params.apiRoot),
}),
{
attempts: 3,
minDelayMs: 500,
maxDelayMs: 2000,
jitter: 0.2,
label: "telegram:downloadFile",
shouldRetry: isRetryableDownloadError,
onRetry: ({ attempt, maxAttempts }) =>
logVerbose(`telegram: file download retry ${attempt}/${maxAttempts}`),
},
);
const originalName = params.telegramFileName ?? fetched.fileName ?? params.filePath;
return saveMediaBuffer(
fetched.buffer,