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:
xingzhou
2026-07-10 06:08:03 +01:00
committed by GitHub
co-authored by Peter Steinberger
parent a7c5b2c6e6
commit 87a652bc37
2 changed files with 134 additions and 3 deletions
@@ -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",
+21 -2
View File
@@ -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,