diff --git a/extensions/byteplus/video-generation-provider.ts b/extensions/byteplus/video-generation-provider.ts index a3bf5bf0110c..c0fe4a9b4e68 100644 --- a/extensions/byteplus/video-generation-provider.ts +++ b/extensions/byteplus/video-generation-provider.ts @@ -10,6 +10,7 @@ import { resolveProviderOperationTimeoutMs, resolveProviderHttpRequestConfig, waitProviderOperationPollInterval, + type ProviderOperationTimeoutMs, } from "openclaw/plugin-sdk/provider-http"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { @@ -120,7 +121,7 @@ async function pollBytePlusTask(params: { async function downloadBytePlusVideo(params: { url: string; - timeoutMs?: number; + timeoutMs?: ProviderOperationTimeoutMs; fetchFn: typeof fetch; }): Promise { const response = await fetchProviderDownloadResponse({ @@ -312,10 +313,11 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider } const video = await downloadBytePlusVideo({ url: videoUrl, - timeoutMs: resolveProviderOperationTimeoutMs({ - deadline, - defaultTimeoutMs: DEFAULT_TIMEOUT_MS, - }), + timeoutMs: () => + resolveProviderOperationTimeoutMs({ + deadline, + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + }), fetchFn, }); return { diff --git a/extensions/minimax/video-generation-provider.ts b/extensions/minimax/video-generation-provider.ts index 2121b7d10023..274ce9065abf 100644 --- a/extensions/minimax/video-generation-provider.ts +++ b/extensions/minimax/video-generation-provider.ts @@ -10,6 +10,7 @@ import { resolveProviderOperationTimeoutMs, resolveProviderHttpRequestConfig, waitProviderOperationPollInterval, + type ProviderOperationTimeoutMs, } from "openclaw/plugin-sdk/provider-http"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { @@ -210,7 +211,7 @@ async function pollMinimaxVideo(params: { async function downloadVideoFromUrl(params: { url: string; - timeoutMs?: number; + timeoutMs?: ProviderOperationTimeoutMs; fetchFn: typeof fetch; }): Promise { const response = await fetchProviderDownloadResponse({ @@ -233,7 +234,7 @@ async function downloadVideoFromUrl(params: { async function downloadVideoFromFileId(params: { fileId: string; headers: Headers; - timeoutMs?: number; + timeoutMs?: ProviderOperationTimeoutMs; baseUrl: string; fetchFn: typeof fetch; }): Promise { @@ -407,20 +408,22 @@ function buildMinimaxVideoProvider(providerId: string): VideoGenerationProvider const video = videoUrl ? await downloadVideoFromUrl({ url: videoUrl, - timeoutMs: resolveProviderOperationTimeoutMs({ - deadline, - defaultTimeoutMs: DEFAULT_TIMEOUT_MS, - }), + timeoutMs: () => + resolveProviderOperationTimeoutMs({ + deadline, + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + }), fetchFn, }) : fileId ? await downloadVideoFromFileId({ fileId, headers, - timeoutMs: resolveProviderOperationTimeoutMs({ - deadline, - defaultTimeoutMs: DEFAULT_TIMEOUT_MS, - }), + timeoutMs: () => + resolveProviderOperationTimeoutMs({ + deadline, + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + }), baseUrl, fetchFn, }) diff --git a/extensions/openai/video-generation-provider.ts b/extensions/openai/video-generation-provider.ts index ab6b6ceb41c4..b18d206ec1e1 100644 --- a/extensions/openai/video-generation-provider.ts +++ b/extensions/openai/video-generation-provider.ts @@ -10,6 +10,7 @@ import { postJsonRequest, resolveProviderOperationTimeoutMs, resolveProviderHttpRequestConfig, + type ProviderOperationTimeoutMs, } from "openclaw/plugin-sdk/provider-http"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { @@ -146,7 +147,7 @@ async function pollOpenAIVideo(params: { async function downloadOpenAIVideo(params: { videoId: string; headers: Headers; - timeoutMs?: number; + timeoutMs?: ProviderOperationTimeoutMs; baseUrl: string; fetchFn: typeof fetch; }): Promise { @@ -355,10 +356,11 @@ export function buildOpenAIVideoGenerationProvider(): VideoGenerationProvider { const video = await downloadOpenAIVideo({ videoId, headers, - timeoutMs: resolveProviderOperationTimeoutMs({ - deadline, - defaultTimeoutMs: DEFAULT_TIMEOUT_MS, - }), + timeoutMs: () => + resolveProviderOperationTimeoutMs({ + deadline, + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + }), baseUrl, fetchFn, }); diff --git a/extensions/runway/video-generation-provider.ts b/extensions/runway/video-generation-provider.ts index 57e8e6f043cc..ed783d619638 100644 --- a/extensions/runway/video-generation-provider.ts +++ b/extensions/runway/video-generation-provider.ts @@ -10,6 +10,7 @@ import { resolveProviderOperationTimeoutMs, resolveProviderHttpRequestConfig, waitProviderOperationPollInterval, + type ProviderOperationTimeoutMs, } from "openclaw/plugin-sdk/provider-http"; import { normalizeLowercaseStringOrEmpty, @@ -249,7 +250,7 @@ async function pollRunwayTask(params: { async function downloadRunwayVideos(params: { urls: string[]; - timeoutMs?: number; + timeoutMs?: ProviderOperationTimeoutMs; fetchFn: typeof fetch; }): Promise { const videos: GeneratedVideoAsset[] = []; @@ -376,10 +377,11 @@ export function buildRunwayVideoGenerationProvider(): VideoGenerationProvider { } const videos = await downloadRunwayVideos({ urls: outputUrls, - timeoutMs: resolveProviderOperationTimeoutMs({ - deadline, - defaultTimeoutMs: DEFAULT_TIMEOUT_MS, - }), + timeoutMs: () => + resolveProviderOperationTimeoutMs({ + deadline, + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + }), fetchFn, }); return { diff --git a/extensions/together/video-generation-provider.ts b/extensions/together/video-generation-provider.ts index 93f2d906f16a..e727d4402e48 100644 --- a/extensions/together/video-generation-provider.ts +++ b/extensions/together/video-generation-provider.ts @@ -9,6 +9,7 @@ import { postJsonRequest, resolveProviderOperationTimeoutMs, resolveProviderHttpRequestConfig, + type ProviderOperationTimeoutMs, } from "openclaw/plugin-sdk/provider-http"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { @@ -99,7 +100,7 @@ async function pollTogetherVideo(params: { async function downloadTogetherVideo(params: { url: string; - timeoutMs?: number; + timeoutMs?: ProviderOperationTimeoutMs; fetchFn: typeof fetch; }): Promise { const response = await fetchProviderDownloadResponse({ @@ -246,10 +247,11 @@ export function buildTogetherVideoGenerationProvider(): VideoGenerationProvider } const video = await downloadTogetherVideo({ url: videoUrl, - timeoutMs: resolveProviderOperationTimeoutMs({ - deadline, - defaultTimeoutMs: DEFAULT_TIMEOUT_MS, - }), + timeoutMs: () => + resolveProviderOperationTimeoutMs({ + deadline, + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + }), fetchFn, }); return { diff --git a/extensions/xai/video-generation-provider.ts b/extensions/xai/video-generation-provider.ts index 6669d4242000..bda9e61fe9dd 100644 --- a/extensions/xai/video-generation-provider.ts +++ b/extensions/xai/video-generation-provider.ts @@ -10,6 +10,7 @@ import { resolveProviderOperationTimeoutMs, resolveProviderHttpRequestConfig, waitProviderOperationPollInterval, + type ProviderOperationTimeoutMs, } from "openclaw/plugin-sdk/provider-http"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { @@ -300,7 +301,7 @@ async function pollXaiVideo(params: { async function downloadXaiVideo(params: { url: string; - timeoutMs?: number; + timeoutMs?: ProviderOperationTimeoutMs; fetchFn: typeof fetch; }): Promise { const response = await fetchProviderDownloadResponse({ @@ -426,10 +427,11 @@ export function buildXaiVideoGenerationProvider(): VideoGenerationProvider { } const video = await downloadXaiVideo({ url: videoUrl, - timeoutMs: resolveProviderOperationTimeoutMs({ - deadline, - defaultTimeoutMs: DEFAULT_TIMEOUT_MS, - }), + timeoutMs: () => + resolveProviderOperationTimeoutMs({ + deadline, + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + }), fetchFn, }); return { diff --git a/src/media-understanding/shared.test.ts b/src/media-understanding/shared.test.ts index c3a31a08d063..2319537f5417 100644 --- a/src/media-understanding/shared.test.ts +++ b/src/media-understanding/shared.test.ts @@ -267,6 +267,35 @@ describe("provider operation deadlines", () => { expect(fetchFn).toHaveBeenCalledTimes(2); expect(sleep).toHaveBeenCalledWith(0, undefined); }); + + it("recomputes remaining download timeout before retry attempts", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + const sleep = vi.fn(async () => undefined); + const fetchFn = vi.fn(async () => { + vi.setSystemTime(2_001); + throw Object.assign(new Error("socket hang up"), { code: "ECONNRESET" }); + }); + const deadline = createProviderOperationDeadline({ + label: "video download", + timeoutMs: 1_000, + }); + + await expect( + fetchProviderDownloadResponse({ + url: "https://cdn.example.com/video.mp4", + init: { method: "GET" }, + timeoutMs: () => resolveProviderOperationTimeoutMs({ deadline, defaultTimeoutMs: 5_000 }), + fetchFn, + provider: "test-video", + requestFailedMessage: "download failed", + retry: { attempts: 2, baseDelayMs: 0, maxDelayMs: 0, sleep }, + }), + ).rejects.toThrow("video download timed out after 1000ms"); + + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(sleep).toHaveBeenCalledWith(0, undefined); + }); }); describe("resolveProviderHttpRequestConfig", () => { @@ -546,6 +575,39 @@ describe("fetchWithTimeoutGuarded", () => { expect(sleep).toHaveBeenCalledWith(0, undefined); }); + it("retries read JSON POST transient HTTP responses", async () => { + fetchWithSsrFGuardMock.mockReset(); + const firstRelease = vi.fn(async () => undefined); + const secondRelease = vi.fn(async () => undefined); + const sleep = vi.fn(async () => undefined); + fetchWithSsrFGuardMock + .mockResolvedValueOnce({ + response: new Response("busy", { status: 503, statusText: "Service Unavailable" }), + finalUrl: "https://api.example.com", + release: firstRelease, + }) + .mockResolvedValueOnce({ + response: new Response(null, { status: 200 }), + finalUrl: "https://api.example.com", + release: secondRelease, + }); + + const result = await postJsonRequest({ + url: "https://api.example.com/v1/analyze", + headers: new Headers(), + body: { media: "base64" }, + fetchFn: fetch, + retryStage: "read", + retry: { attempts: 2, baseDelayMs: 0, maxDelayMs: 0, sleep }, + }); + + expect(result.response.status).toBe(200); + expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(2); + expect(firstRelease).toHaveBeenCalledOnce(); + expect(secondRelease).not.toHaveBeenCalled(); + expect(sleep).toHaveBeenCalledWith(0, undefined); + }); + it("forwards explicit pinDns overrides to transcription requests", async () => { fetchWithSsrFGuardMock.mockResolvedValue({ response: new Response(null, { status: 200 }), diff --git a/src/media-understanding/shared.ts b/src/media-understanding/shared.ts index fc8a6653ca47..4f3931d04e62 100644 --- a/src/media-understanding/shared.ts +++ b/src/media-understanding/shared.ts @@ -1,5 +1,8 @@ import path from "node:path"; -import { assertOkOrThrowHttpError } from "../agents/provider-http-errors.js"; +import { + assertOkOrThrowHttpError, + createProviderHttpError, +} from "../agents/provider-http-errors.js"; export { assertOkOrThrowHttpError } from "../agents/provider-http-errors.js"; import type { ProviderRequestCapability, @@ -482,14 +485,25 @@ async function postGuardedRequest(params: { retryStage?: ProviderOperationRetryStage; retry?: TransientProviderRetryConfig; }) { - const operation = () => - fetchWithTimeoutGuarded( + const operation = async () => { + const result = await fetchWithTimeoutGuarded( params.url, params.init, params.timeoutMs, params.fetchFn, params.guardedOptions, ); + if (params.retryStage && isTransientProviderHttpStatus(result.response.status)) { + try { + throw await createProviderHttpError(result.response, "provider POST request failed", { + statusPrefix: "HTTP ", + }); + } finally { + await result.release(); + } + } + return result; + }; if (!params.retryStage) { return await operation(); } @@ -501,6 +515,10 @@ async function postGuardedRequest(params: { }); } +function isTransientProviderHttpStatus(status: number): boolean { + return status === 500 || status === 502 || status === 503 || status === 504; +} + export async function postJsonRequest( params: { url: string; diff --git a/src/video-generation/dashscope-compatible.ts b/src/video-generation/dashscope-compatible.ts index 347719349818..478e4fb9728d 100644 --- a/src/video-generation/dashscope-compatible.ts +++ b/src/video-generation/dashscope-compatible.ts @@ -6,6 +6,7 @@ import { postJsonRequest, resolveProviderOperationTimeoutMs, waitProviderOperationPollInterval, + type ProviderOperationTimeoutMs, } from "openclaw/plugin-sdk/provider-http"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import type { @@ -275,7 +276,7 @@ export async function runDashscopeVideoGenerationTask(params: { const videos = await downloadDashscopeGeneratedVideos({ providerLabel: params.providerLabel, urls, - timeoutMs: resolveProviderOperationTimeoutMs({ deadline, defaultTimeoutMs }), + timeoutMs: () => resolveProviderOperationTimeoutMs({ deadline, defaultTimeoutMs }), fetchFn: params.fetchFn, defaultTimeoutMs, }); @@ -296,7 +297,7 @@ export async function runDashscopeVideoGenerationTask(params: { export async function downloadDashscopeGeneratedVideos(params: { providerLabel: string; urls: string[]; - timeoutMs?: number; + timeoutMs?: ProviderOperationTimeoutMs; fetchFn: typeof fetch; defaultTimeoutMs?: number; }): Promise {