Merge 37c8b3dc08a234e51c06f9737c8823d75db4a624 into 8a05c05596ca9ba0735dafd8e359885de4c2c969

This commit is contained in:
Jerry-Xin 2026-03-21 06:06:24 +00:00 committed by GitHub
commit eba8a11b2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 149 additions and 14 deletions

View File

@ -111,6 +111,16 @@ function makeCtx(
}; };
} }
/**
* Re-export the real MediaFetchError so tests throw instances that pass the
* `instanceof` check inside `isRetryableDownloadError`. The vi.mock above
* preserves the original class via `...actual`, so both sides reference the
* same constructor.
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports -- dynamic import after mock registration
const { MediaFetchError: MockMediaFetchError } =
await vi.importActual<typeof import("openclaw/plugin-sdk/media-runtime")>("openclaw/plugin-sdk/media-runtime");
function setupTransientGetFileRetry() { function setupTransientGetFileRetry() {
const getFile = vi const getFile = vi
.fn() .fn()
@ -200,15 +210,96 @@ 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" }); 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( await expect(
resolveMedia(makeCtx("voice", getFile), MAX_MEDIA_BYTES, BOT_TOKEN), resolveMedia(makeCtx("voice", getFile), MAX_MEDIA_BYTES, BOT_TOKEN),
).rejects.toThrow("download failed"); ).rejects.toThrow("HTTP 404 Not Found");
expect(getFile).toHaveBeenCalledTimes(1); expect(getFile).toHaveBeenCalledTimes(1);
expect(fetchRemoteMedia).toHaveBeenCalledTimes(1);
});
it("retries fetchRemoteMedia on transient HTTP 5xx error and succeeds", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "voice/file_0.oga" });
fetchRemoteMedia
.mockRejectedValueOnce(new MockMediaFetchError("http_error", "HTTP 502 Bad Gateway"))
.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 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 () => { 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 { GrammyError } from "grammy";
import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime";
import { retryAsync } 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 { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime";
import { logVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; import { logVerbose, warn } from "openclaw/plugin-sdk/runtime-env";
import { import {
@ -60,6 +60,37 @@ function isRetryableGetFileError(err: unknown): boolean {
return true; 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) {
// Retry transient fetch failures (network issues, timeouts, connection drops).
if (err.code === "fetch_failed") {
return true;
}
// Retry transient HTTP 5xx server errors; do not retry 4xx or policy violations.
if (err.code === "http_error") {
const match = /HTTP (\d{3})/.exec(err.message);
if (match) {
const status = Number(match[1]);
return status >= 500;
}
}
return false;
}
// 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"]) { function resolveMediaFileRef(msg: TelegramContext["message"]) {
return ( return (
msg.photo?.[msg.photo.length - 1] ?? msg.photo?.[msg.photo.length - 1] ??
@ -149,16 +180,29 @@ async function downloadAndSaveTelegramFile(params: {
} }
const apiBase = resolveTelegramApiBase(params.apiRoot); const apiBase = resolveTelegramApiBase(params.apiRoot);
const url = `${apiBase}/file/bot${params.token}/${params.filePath}`; const url = `${apiBase}/file/bot${params.token}/${params.filePath}`;
const fetched = await fetchRemoteMedia({ const fetched = await retryAsync(
url, () =>
fetchImpl: params.transport.sourceFetch, fetchRemoteMedia({
dispatcherAttempts: params.transport.dispatcherAttempts, url,
shouldRetryFetchError: shouldRetryTelegramTransportFallback, fetchImpl: params.transport.sourceFetch,
filePathHint: params.filePath, dispatcherAttempts: params.transport.dispatcherAttempts,
maxBytes: params.maxBytes, shouldRetryFetchError: shouldRetryTelegramTransportFallback,
readIdleTimeoutMs: TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS, filePathHint: params.filePath,
ssrfPolicy: buildTelegramMediaSsrfPolicy(params.apiRoot), 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; const originalName = params.telegramFileName ?? fetched.fileName ?? params.filePath;
return saveMediaBuffer( return saveMediaBuffer(
fetched.buffer, fetched.buffer,