Merge 37c8b3dc08a234e51c06f9737c8823d75db4a624 into 8a05c05596ca9ba0735dafd8e359885de4c2c969
This commit is contained in:
commit
eba8a11b2f
@ -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 () => {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user