diff --git a/extensions/byteplus/video-generation-provider.ts b/extensions/byteplus/video-generation-provider.ts index d4000469102..a3bf5bf0110 100644 --- a/extensions/byteplus/video-generation-provider.ts +++ b/extensions/byteplus/video-generation-provider.ts @@ -90,10 +90,11 @@ async function pollBytePlusTask(params: { method: "GET", headers: params.headers, }, - timeoutMs: resolveProviderOperationTimeoutMs({ - deadline, - defaultTimeoutMs: DEFAULT_TIMEOUT_MS, - }), + timeoutMs: () => + resolveProviderOperationTimeoutMs({ + deadline, + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + }), fetchFn: params.fetchFn, provider: "byteplus", requestFailedMessage: "BytePlus video status request failed", diff --git a/extensions/minimax/provider-http.test-helpers.ts b/extensions/minimax/provider-http.test-helpers.ts index 1f29f95144d..370113667fc 100644 --- a/extensions/minimax/provider-http.test-helpers.ts +++ b/extensions/minimax/provider-http.test-helpers.ts @@ -47,12 +47,18 @@ const minimaxProviderHttpMocks = vi.hoisted(() => ({ })), })); +function resolveMockProviderTimeoutMs( + timeoutMs: FetchProviderOperationResponseParams["timeoutMs"], +) { + return typeof timeoutMs === "function" ? timeoutMs() : (timeoutMs ?? 60_000); +} + minimaxProviderHttpMocks.fetchProviderOperationResponseMock.mockImplementation( async (params: FetchProviderOperationResponseParams) => { const response = await minimaxProviderHttpMocks.fetchWithTimeoutMock( params.url, params.init ?? {}, - params.timeoutMs ?? 60_000, + resolveMockProviderTimeoutMs(params.timeoutMs), params.fetchFn, ); if (params.requestFailedMessage) { @@ -70,7 +76,7 @@ minimaxProviderHttpMocks.fetchProviderDownloadResponseMock.mockImplementation( const response = await minimaxProviderHttpMocks.fetchWithTimeoutMock( params.url, params.init ?? {}, - params.timeoutMs ?? 60_000, + resolveMockProviderTimeoutMs(params.timeoutMs), params.fetchFn, ); await minimaxProviderHttpMocks.assertOkOrThrowHttpErrorMock( diff --git a/extensions/minimax/video-generation-provider.ts b/extensions/minimax/video-generation-provider.ts index 9fde36c110a..2121b7d1002 100644 --- a/extensions/minimax/video-generation-provider.ts +++ b/extensions/minimax/video-generation-provider.ts @@ -179,10 +179,11 @@ async function pollMinimaxVideo(params: { method: "GET", headers: params.headers, }, - timeoutMs: resolveProviderOperationTimeoutMs({ - deadline, - defaultTimeoutMs: DEFAULT_TIMEOUT_MS, - }), + timeoutMs: () => + resolveProviderOperationTimeoutMs({ + deadline, + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + }), fetchFn: params.fetchFn, provider: "minimax", requestFailedMessage: "MiniMax video status request failed", diff --git a/extensions/runway/video-generation-provider.ts b/extensions/runway/video-generation-provider.ts index 469ebfc177b..57e8e6f043c 100644 --- a/extensions/runway/video-generation-provider.ts +++ b/extensions/runway/video-generation-provider.ts @@ -216,10 +216,11 @@ async function pollRunwayTask(params: { method: "GET", headers: params.headers, }, - timeoutMs: resolveProviderOperationTimeoutMs({ - deadline, - defaultTimeoutMs: DEFAULT_TIMEOUT_MS, - }), + timeoutMs: () => + resolveProviderOperationTimeoutMs({ + deadline, + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + }), fetchFn: params.fetchFn, provider: "runway", requestFailedMessage: "Runway video status request failed", diff --git a/extensions/xai/video-generation-provider.ts b/extensions/xai/video-generation-provider.ts index 4da9e440b78..6669d424200 100644 --- a/extensions/xai/video-generation-provider.ts +++ b/extensions/xai/video-generation-provider.ts @@ -269,10 +269,11 @@ async function pollXaiVideo(params: { method: "GET", headers: params.headers, }, - timeoutMs: resolveProviderOperationTimeoutMs({ - deadline, - defaultTimeoutMs: DEFAULT_TIMEOUT_MS, - }), + timeoutMs: () => + resolveProviderOperationTimeoutMs({ + deadline, + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + }), fetchFn: params.fetchFn, provider: "xai", requestFailedMessage: "xAI video status request failed", diff --git a/src/media-understanding/shared.test.ts b/src/media-understanding/shared.test.ts index cd981697dd6..c3a31a08d06 100644 --- a/src/media-understanding/shared.test.ts +++ b/src/media-understanding/shared.test.ts @@ -213,6 +213,39 @@ describe("provider operation deadlines", () => { expect(fetchFn).toHaveBeenCalledTimes(2); }); + it("recomputes remaining poll timeout before retry attempts", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + const fetchFn = vi.fn(async () => { + vi.setSystemTime(2_001); + return new Response("busy", { status: 503, statusText: "Service Unavailable" }); + }); + + const result = pollProviderOperationJson<{ status?: string }>({ + url: "https://api.example.com/v1/videos/task-1", + headers: new Headers({ authorization: "Bearer test" }), + deadline: createProviderOperationDeadline({ + label: "video generation task task-1", + timeoutMs: 1_000, + }), + defaultTimeoutMs: 5_000, + fetchFn, + maxAttempts: 3, + pollIntervalMs: 1_000, + requestFailedMessage: "status failed", + timeoutMessage: "task timed out", + isComplete: (payload) => payload.status === "completed", + }); + const assertion = expect(result).rejects.toThrow( + "video generation task task-1 timed out after 1000ms", + ); + + await vi.advanceTimersByTimeAsync(250); + + await assertion; + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + it("retries transient generated asset downloads", async () => { const sleep = vi.fn(async () => undefined); const fetchFn = vi diff --git a/src/media-understanding/shared.ts b/src/media-understanding/shared.ts index d3b76f9fe2e..fc8a6653ca4 100644 --- a/src/media-understanding/shared.ts +++ b/src/media-understanding/shared.ts @@ -71,6 +71,8 @@ export type ProviderOperationDeadline = { timeoutMs?: number; }; +export type ProviderOperationTimeoutMs = number | (() => number); + export function createProviderOperationDeadline(params: { timeoutMs?: number; label: string; @@ -142,10 +144,11 @@ export async function pollProviderOperationJson(params: { method: "GET", headers: params.headers, }, - timeoutMs: resolveProviderOperationTimeoutMs({ - deadline: params.deadline, - defaultTimeoutMs: params.defaultTimeoutMs, - }), + timeoutMs: () => + resolveProviderOperationTimeoutMs({ + deadline: params.deadline, + defaultTimeoutMs: params.defaultTimeoutMs, + }), fetchFn: params.fetchFn, requestFailedMessage: params.requestFailedMessage, }); @@ -169,7 +172,7 @@ export async function fetchProviderOperationResponse(params: { stage: ProviderOperationRetryStage; url: string; init?: RequestInit; - timeoutMs?: number; + timeoutMs?: ProviderOperationTimeoutMs; fetchFn: typeof fetch; provider?: string; requestFailedMessage?: string; @@ -183,7 +186,7 @@ export async function fetchProviderOperationResponse(params: { const response = await fetchWithTimeout( params.url, params.init ?? {}, - params.timeoutMs ?? DEFAULT_GUARDED_HTTP_TIMEOUT_MS, + resolveProviderOperationRequestTimeoutMs(params.timeoutMs), params.fetchFn, ); if (params.requestFailedMessage) { @@ -197,7 +200,7 @@ export async function fetchProviderOperationResponse(params: { export async function fetchProviderDownloadResponse(params: { url: string; init?: RequestInit; - timeoutMs?: number; + timeoutMs?: ProviderOperationTimeoutMs; fetchFn: typeof fetch; provider?: string; requestFailedMessage: string; @@ -215,6 +218,16 @@ export async function fetchProviderDownloadResponse(params: { }); } +function resolveProviderOperationRequestTimeoutMs( + timeoutMs: ProviderOperationTimeoutMs | undefined, +): number { + const resolved = typeof timeoutMs === "function" ? timeoutMs() : timeoutMs; + if (typeof resolved !== "number" || !Number.isFinite(resolved) || resolved <= 0) { + return DEFAULT_GUARDED_HTTP_TIMEOUT_MS; + } + return resolved; +} + function resolveGuardedHttpTimeoutMs(timeoutMs: number | undefined): number { if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0) { return DEFAULT_GUARDED_HTTP_TIMEOUT_MS; diff --git a/src/plugin-sdk/provider-http.ts b/src/plugin-sdk/provider-http.ts index b3abcd30290..095150563d6 100644 --- a/src/plugin-sdk/provider-http.ts +++ b/src/plugin-sdk/provider-http.ts @@ -31,7 +31,10 @@ export { sanitizeConfiguredModelProviderRequest, waitProviderOperationPollInterval, } from "../media-understanding/shared.js"; -export type { ProviderOperationDeadline } from "../media-understanding/shared.js"; +export type { + ProviderOperationDeadline, + ProviderOperationTimeoutMs, +} from "../media-understanding/shared.js"; export { executeProviderOperationWithRetry, providerOperationRetryConfig, diff --git a/src/plugin-sdk/test-helpers/provider-http-mocks.ts b/src/plugin-sdk/test-helpers/provider-http-mocks.ts index 5526aba5693..108009ff9e8 100644 --- a/src/plugin-sdk/test-helpers/provider-http-mocks.ts +++ b/src/plugin-sdk/test-helpers/provider-http-mocks.ts @@ -63,12 +63,18 @@ const providerHttpMocks = vi.hoisted(() => ({ })), })); +function resolveMockProviderTimeoutMs( + timeoutMs: FetchProviderOperationResponseParams["timeoutMs"], +) { + return typeof timeoutMs === "function" ? timeoutMs() : (timeoutMs ?? 60_000); +} + providerHttpMocks.fetchProviderOperationResponseMock.mockImplementation( async (params: FetchProviderOperationResponseParams) => { const response = await providerHttpMocks.fetchWithTimeoutMock( params.url, params.init ?? {}, - params.timeoutMs ?? 60_000, + resolveMockProviderTimeoutMs(params.timeoutMs), params.fetchFn, ); if (params.requestFailedMessage) { @@ -83,7 +89,7 @@ providerHttpMocks.fetchProviderDownloadResponseMock.mockImplementation( const response = await providerHttpMocks.fetchWithTimeoutMock( params.url, params.init ?? {}, - params.timeoutMs ?? 60_000, + resolveMockProviderTimeoutMs(params.timeoutMs), params.fetchFn, ); await providerHttpMocks.assertOkOrThrowHttpErrorMock(response, params.requestFailedMessage); diff --git a/src/video-generation/dashscope-compatible.ts b/src/video-generation/dashscope-compatible.ts index 78bcafa71a3..34771934981 100644 --- a/src/video-generation/dashscope-compatible.ts +++ b/src/video-generation/dashscope-compatible.ts @@ -182,7 +182,7 @@ export async function pollDashscopeVideoTaskUntilComplete(params: { method: "GET", headers: params.headers, }, - timeoutMs: resolveProviderOperationTimeoutMs({ deadline, defaultTimeoutMs }), + timeoutMs: () => resolveProviderOperationTimeoutMs({ deadline, defaultTimeoutMs }), fetchFn: params.fetchFn, provider: params.providerLabel, requestFailedMessage: `${params.providerLabel} video-generation task poll failed`,