fix(telegram): add retry logic to media file download
This commit is contained in:
parent
8a05c05596
commit
e2c4993be7
@ -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 () => {
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user