mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(fal): image generation hangs on slow generated-image downloads (#103071)
* fix(fal): timeout generated image downloads * fix(fal): honor image operation timeout budget --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
co-authored by
Peter Steinberger
parent
a7c5b2c6e6
commit
87a652bc37
@@ -26,9 +26,10 @@ function expectFalJsonPost(params: { call: number; url: string; body: Record<str
|
||||
expect(JSON.parse(String(request.init?.body))).toEqual(params.body);
|
||||
}
|
||||
|
||||
function expectFalDownload(params: { call: number; url: string }) {
|
||||
function expectFalDownload(params: { call: number; url: string; timeoutMs?: number }) {
|
||||
expect(fetchWithSsrFGuardMock.mock.calls[params.call - 1]?.[0]).toEqual({
|
||||
url: params.url,
|
||||
timeoutMs: params.timeoutMs ?? 30_000,
|
||||
policy: undefined,
|
||||
auditContext: "fal-image-download",
|
||||
});
|
||||
@@ -41,6 +42,7 @@ describe("fal image-generation provider", () => {
|
||||
|
||||
afterEach(() => {
|
||||
setFalFetchGuardForTesting(null);
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -146,6 +148,116 @@ describe("fal image-generation provider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shares an explicit operation deadline across generated image downloads", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-10T00:00:00Z"));
|
||||
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockImplementation(async () => {
|
||||
vi.advanceTimersByTime(5_000);
|
||||
return {
|
||||
apiKey: "fal-test-key",
|
||||
source: "env",
|
||||
mode: "api-key",
|
||||
};
|
||||
});
|
||||
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
|
||||
fetchWithSsrFGuardMock
|
||||
.mockImplementationOnce(async () => {
|
||||
vi.advanceTimersByTime(10_000);
|
||||
return {
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
images: [
|
||||
{ url: "https://v3.fal.media/files/example/first.png" },
|
||||
{ url: "https://v3.fal.media/files/example/second.png" },
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
release: vi.fn(async () => {}),
|
||||
};
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
vi.advanceTimersByTime(20_000);
|
||||
return {
|
||||
response: new Response(Buffer.from("first"), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/png" },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
};
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
response: new Response(Buffer.from("second"), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/png" },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const result = await buildFalImageGenerationProvider().generateImage({
|
||||
provider: "fal",
|
||||
model: "fal-ai/flux/dev",
|
||||
prompt: "draw two cats",
|
||||
cfg: {},
|
||||
timeoutMs: 180_000,
|
||||
count: 2,
|
||||
});
|
||||
|
||||
expect(fetchWithSsrFGuardMock.mock.calls[0]?.[0]?.timeoutMs).toBe(175_000);
|
||||
expectFalDownload({
|
||||
call: 2,
|
||||
url: "https://v3.fal.media/files/example/first.png",
|
||||
timeoutMs: 165_000,
|
||||
});
|
||||
expectFalDownload({
|
||||
call: 3,
|
||||
url: "https://v3.fal.media/files/example/second.png",
|
||||
timeoutMs: 145_000,
|
||||
});
|
||||
expect(result.images.map((image) => image.buffer.toString())).toEqual(["first", "second"]);
|
||||
});
|
||||
|
||||
it("releases a timed-out generated image download", async () => {
|
||||
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
|
||||
apiKey: "fal-test-key",
|
||||
source: "env",
|
||||
mode: "api-key",
|
||||
});
|
||||
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
|
||||
const releaseDownload = vi.fn(async () => {});
|
||||
fetchWithSsrFGuardMock
|
||||
.mockResolvedValueOnce({
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
images: [{ url: "https://v3.fal.media/files/example/slow.png" }],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
release: vi.fn(async () => {}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
response: new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.error(new DOMException("timed out", "TimeoutError"));
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "image/png" } },
|
||||
),
|
||||
release: releaseDownload,
|
||||
});
|
||||
|
||||
await expect(
|
||||
buildFalImageGenerationProvider().generateImage({
|
||||
provider: "fal",
|
||||
model: "fal-ai/flux/dev",
|
||||
prompt: "draw a cat",
|
||||
cfg: {},
|
||||
}),
|
||||
).rejects.toMatchObject({ name: "TimeoutError" });
|
||||
expect(releaseDownload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects generated image downloads that exceed the configured media cap", async () => {
|
||||
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
|
||||
apiKey: "fal-test-key",
|
||||
|
||||
@@ -12,7 +12,10 @@ import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
|
||||
import {
|
||||
assertOkOrThrowHttpError,
|
||||
assertOkOrThrowProviderError,
|
||||
createProviderOperationDeadline,
|
||||
readProviderJsonResponse,
|
||||
resolveProviderOperationTimeoutMs,
|
||||
type ProviderOperationDeadline,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import {
|
||||
@@ -125,6 +128,7 @@ const GROK_IMAGINE_SUPPORTED_RESOLUTIONS: readonly ("1K" | "2K" | "4K")[] = ["1K
|
||||
const KREA_CREATIVITY_LEVELS = ["raw", "low", "medium", "high"] as const;
|
||||
|
||||
const FAL_IMAGE_MALFORMED_RESPONSE = "fal image generation response malformed";
|
||||
const DEFAULT_HTTP_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_GENERATED_IMAGE_MAX_BYTES = 6 * 1024 * 1024;
|
||||
|
||||
type FalImageSize = string | { width: number; height: number };
|
||||
@@ -591,6 +595,7 @@ function resolveGeneratedImageMaxBytes(req: {
|
||||
|
||||
async function fetchImageBuffer(
|
||||
url: string,
|
||||
deadline: ProviderOperationDeadline,
|
||||
networkPolicy?: FalNetworkPolicy,
|
||||
maxBytes = DEFAULT_GENERATED_IMAGE_MAX_BYTES,
|
||||
): Promise<{ buffer: Buffer; mimeType: string }> {
|
||||
@@ -609,6 +614,10 @@ async function fetchImageBuffer(
|
||||
})();
|
||||
const { response, release } = await falFetchGuard({
|
||||
url,
|
||||
timeoutMs: resolveProviderOperationTimeoutMs({
|
||||
deadline,
|
||||
defaultTimeoutMs: deadline.timeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS,
|
||||
}),
|
||||
policy: downloadPolicy,
|
||||
auditContext: "fal-image-download",
|
||||
});
|
||||
@@ -705,6 +714,10 @@ export function buildFalImageGenerationProvider(): ImageGenerationProvider {
|
||||
},
|
||||
},
|
||||
async generateImage(req) {
|
||||
const deadline = createProviderOperationDeadline({
|
||||
timeoutMs: req.timeoutMs,
|
||||
label: "fal image generation",
|
||||
});
|
||||
const inputImageCount = req.inputImages?.length ?? 0;
|
||||
const hasInputImages = inputImageCount > 0;
|
||||
const requestedModel = req.model?.trim() || DEFAULT_FAL_IMAGE_MODEL;
|
||||
@@ -774,7 +787,13 @@ export function buildFalImageGenerationProvider(): ImageGenerationProvider {
|
||||
headers,
|
||||
body: JSON.stringify(requestBody),
|
||||
},
|
||||
timeoutMs: req.timeoutMs,
|
||||
timeoutMs:
|
||||
deadline.timeoutMs === undefined
|
||||
? undefined
|
||||
: resolveProviderOperationTimeoutMs({
|
||||
deadline,
|
||||
defaultTimeoutMs: deadline.timeoutMs,
|
||||
}),
|
||||
policy: networkPolicy.apiPolicy,
|
||||
dispatcherPolicy,
|
||||
auditContext: "fal-image-generate",
|
||||
@@ -792,7 +811,7 @@ export function buildFalImageGenerationProvider(): ImageGenerationProvider {
|
||||
if (!url) {
|
||||
throw new Error(FAL_IMAGE_MALFORMED_RESPONSE);
|
||||
}
|
||||
const downloaded = await fetchImageBuffer(url, networkPolicy, maxImageBytes);
|
||||
const downloaded = await fetchImageBuffer(url, deadline, networkPolicy, maxImageBytes);
|
||||
imageIndex += 1;
|
||||
images.push({
|
||||
buffer: downloaded.buffer,
|
||||
|
||||
Reference in New Issue
Block a user