diff --git a/extensions/openai/video-generation-provider.test.ts b/extensions/openai/video-generation-provider.test.ts index f73e1f87bf79..02575dc3013b 100644 --- a/extensions/openai/video-generation-provider.test.ts +++ b/extensions/openai/video-generation-provider.test.ts @@ -11,6 +11,7 @@ const { fetchWithTimeoutMock, fetchWithTimeoutGuardedMock, pollProviderOperationJsonMock, + assertOkOrThrowHttpErrorMock, resolveProviderHttpRequestConfigMock, sanitizeConfiguredModelProviderRequestMock, } = getProviderHttpMocks(); @@ -303,6 +304,60 @@ describe("openai video generation provider", () => { }); }); + it("releases guarded local video download requests when HTTP errors throw", async () => { + const release = vi.fn(async () => {}); + assertOkOrThrowHttpErrorMock + .mockImplementationOnce(async () => {}) + .mockImplementationOnce(async () => {}) + .mockImplementationOnce(async (_response, label) => { + throw new Error(label); + }); + postJsonRequestMock.mockResolvedValue({ + response: { + json: async () => ({ + id: "vid_local", + model: "sora-2", + status: "queued", + }), + }, + release: vi.fn(async () => {}), + }); + fetchWithTimeoutMock.mockResolvedValueOnce({ + json: async () => ({ + id: "vid_local", + model: "sora-2", + status: "completed", + }), + }); + fetchWithTimeoutGuardedMock.mockResolvedValueOnce({ + response: new Response("busy", { status: 503, statusText: "Service Unavailable" }), + finalUrl: "http://127.0.0.1:44080/v1/videos/vid_local/content?variant=video", + release, + }); + + const provider = buildOpenAIVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "openai", + model: "sora-2", + prompt: "Render via local relay", + cfg: { + models: { + providers: { + openai: { + baseUrl: "http://127.0.0.1:44080/v1", + request: { allowPrivateNetwork: true }, + models: [], + }, + }, + }, + }, + }), + ).rejects.toThrow("OpenAI video download failed"); + + expect(release).toHaveBeenCalledTimes(1); + }); + it("uses multipart input_reference for video-to-video uploads", async () => { fetchWithTimeoutMock .mockResolvedValueOnce({ diff --git a/extensions/openai/video-generation-provider.ts b/extensions/openai/video-generation-provider.ts index ff5dc989cb00..c7d2a7e4772b 100644 --- a/extensions/openai/video-generation-provider.ts +++ b/extensions/openai/video-generation-provider.ts @@ -198,8 +198,13 @@ async function fetchOpenAIVideoDownload( auditContext: "openai-video-download", }, ); - await assertOkOrThrowHttpError(result.response, "OpenAI video download failed"); - return result; + try { + await assertOkOrThrowHttpError(result.response, "OpenAI video download failed"); + return result; + } catch (error) { + await result.release(); + throw error; + } } async function downloadOpenAIVideo( diff --git a/src/media-understanding/shared.test.ts b/src/media-understanding/shared.test.ts index 9ce32876cbe7..c393a75b5b09 100644 --- a/src/media-understanding/shared.test.ts +++ b/src/media-understanding/shared.test.ts @@ -188,6 +188,48 @@ describe("provider operation deadlines", () => { expect(release).toHaveBeenCalledTimes(1); }); + it("retries guarded transient provider status failures while polling", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + const firstRelease = vi.fn(async () => {}); + const secondRelease = vi.fn(async () => {}); + fetchWithSsrFGuardMock + .mockResolvedValueOnce({ + response: new Response("busy", { status: 503, statusText: "Service Unavailable" }), + finalUrl: "https://api.example.com/v1/videos/task-1", + release: firstRelease, + }) + .mockResolvedValueOnce({ + response: new Response(JSON.stringify({ status: "completed" })), + finalUrl: "https://api.example.com/v1/videos/task-1", + release: secondRelease, + }); + + 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: 10_000, + }), + defaultTimeoutMs: 5_000, + fetchFn: fetch, + maxAttempts: 3, + pollIntervalMs: 1_000, + requestFailedMessage: "status failed", + timeoutMessage: "task timed out", + allowPrivateNetwork: true, + isComplete: (payload) => payload.status === "completed", + }); + + await vi.advanceTimersByTimeAsync(250); + + await expect(result).resolves.toEqual({ status: "completed" }); + expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(2); + expect(firstRelease).toHaveBeenCalledTimes(1); + expect(secondRelease).toHaveBeenCalledTimes(1); + }); + it("throws provider failure messages while polling status JSON", async () => { const fetchFn = vi .fn() diff --git a/src/media-understanding/shared.ts b/src/media-understanding/shared.ts index a24ea72f9901..a77f4801c315 100644 --- a/src/media-understanding/shared.ts +++ b/src/media-understanding/shared.ts @@ -174,15 +174,16 @@ export async function pollProviderOperationJson( const guardedOptions = resolveGuardedRequestOptions(params); const payload = guardedOptions ? await (async () => { - const result = await fetchWithTimeoutGuarded( - params.url, + const result = await fetchGuardedProviderOperationResponse({ + stage: "poll", + url: params.url, init, - timeoutMs(), - params.fetchFn, + timeoutMs, + fetchFn: params.fetchFn, + requestFailedMessage: params.requestFailedMessage, guardedOptions, - ); + }); try { - await assertOkOrThrowHttpError(result.response, params.requestFailedMessage); return (await readProviderJsonObjectResponse( result.response, params.requestFailedMessage, @@ -473,6 +474,42 @@ function resolveGuardedRequestOptions( }; } +async function fetchGuardedProviderOperationResponse(params: { + stage: ProviderOperationRetryStage; + url: string; + init: RequestInit; + timeoutMs?: ProviderOperationTimeoutMs; + fetchFn: typeof fetch; + provider?: string; + requestFailedMessage?: string; + retry?: TransientProviderRetryConfig; + guardedOptions: GuardedProviderRequestOptions; +}): Promise { + return await executeProviderOperationWithRetry({ + provider: params.provider ?? "provider-http", + stage: params.stage, + retry: params.retry, + operation: async () => { + const result = await fetchWithTimeoutGuarded( + params.url, + params.init, + resolveProviderOperationRequestTimeoutMs(params.timeoutMs), + params.fetchFn, + params.guardedOptions, + ); + try { + if (params.requestFailedMessage) { + await assertOkOrThrowHttpError(result.response, params.requestFailedMessage); + } + return result; + } catch (error) { + await result.release(); + throw error; + } + }, + }); +} + type GuardedPostRequestRetryOptions = { /** * POST requests default to no retry because many provider endpoints create