fix(provider): preserve retry deadlines

This commit is contained in:
Peter Steinberger
2026-05-13 08:06:44 +01:00
parent 47ba73de27
commit af021aac8d
10 changed files with 94 additions and 29 deletions

View File

@@ -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",

View File

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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

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

View File

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

View File

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

View File

@@ -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);

View File

@@ -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`,