diff --git a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts index 86d6e608dce..f54d62abe67 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts @@ -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: "" }), + ); + }); + + 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 () => { diff --git a/extensions/telegram/src/bot/delivery.resolve-media.ts b/extensions/telegram/src/bot/delivery.resolve-media.ts index 2e552529dec..d096a1beb05 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media.ts @@ -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,