fix(provider): retry post status and download deadlines

This commit is contained in:
Peter Steinberger
2026-05-13 10:26:59 +01:00
parent 8b0b4ea82f
commit bd326cdd5e
9 changed files with 134 additions and 40 deletions

View File

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

View File

@@ -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<GeneratedVideoAsset> {
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<GeneratedVideoAsset> {
@@ -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,
})

View File

@@ -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<GeneratedVideoAsset> {
@@ -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,
});

View File

@@ -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<GeneratedVideoAsset[]> {
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 {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<GeneratedVideoAsset[]> {