mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-08 02:52:15 +00:00
fix(provider): retry post status and download deadlines
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
Reference in New Issue
Block a user