fix: preserve guarded video operation cleanup

This commit is contained in:
Shakker
2026-05-23 00:16:53 +01:00
committed by Shakker
parent 31b5145594
commit ed7d99aa0e
4 changed files with 147 additions and 8 deletions

View File

@@ -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({

View File

@@ -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(

View File

@@ -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<typeof fetch>()

View File

@@ -174,15 +174,16 @@ export async function pollProviderOperationJson<TPayload>(
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<GuardedFetchResult> {
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