From 0331e8126db9add8958eb14f3158907b7fe7771e Mon Sep 17 00:00:00 2001 From: Max Garmash Date: Thu, 4 Jun 2026 02:19:49 +0500 Subject: [PATCH] fix: add AbortController timeout to fetchImageEndpoint (#3105) * fix: add AbortController timeout to fetchImageEndpoint fetchImageEndpoint uses raw fetch() without timeout control. Long-running image generation requests (~20-30s) are killed by Next.js default timeout, producing upstream_error responses. Replace fetch() with fetchWithTimeout() from shared utils, defaulting to FETCH_TIMEOUT_MS (120s via OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS). * fix: return 504 for image fetch timeout, add tests Address review feedback from gemini-code-assist: - Import FetchTimeoutError and catch it in fetchImageEndpoint - Return 504 (Gateway Timeout) instead of 502 for timeout/AbortError - Add 3 focused unit tests for timeout, non-timeout, and success paths Clarifies timeout semantics: timeout is a gateway timeout (504), not a bad gateway (502). Non-timeout fetch errors remain 502. --------- Co-authored-by: mgarmash --- open-sse/handlers/imageGeneration.ts | 40 +++++++-- .../image-generation-fetch-timeout.test.ts | 81 +++++++++++++++++++ 2 files changed, 113 insertions(+), 8 deletions(-) create mode 100644 tests/unit/image-generation-fetch-timeout.test.ts diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 7696813d0d..98f5f32c19 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -41,6 +41,7 @@ import { extractComfyOutputFiles, } from "../utils/comfyuiClient.ts"; import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; +import { FetchTimeoutError, fetchWithTimeout, getConfiguredTimeout } from "@/shared/utils/fetchTimeout"; import { sanitizeErrorMessage, sanitizeUpstreamDetails } from "../utils/error.ts"; interface KieImageOptions { @@ -2414,11 +2415,33 @@ function saveImageErrorResult({ provider, model, status, startTime, error, reque */ async function fetchImageEndpoint(url, headers, body, provider, log) { try { - const response = await fetch(url, { - method: "POST", - headers, - body, - }); + let response; + try { + response = await fetchWithTimeout(url, { + method: "POST", + headers, + body, + timeoutMs: getConfiguredTimeout(), + }); + } catch (err: unknown) { + const isAbortError = + typeof err === "object" && + err !== null && + "name" in err && + (err as { name?: unknown }).name === "AbortError"; + if (err instanceof FetchTimeoutError || isAbortError) { + const message = err instanceof Error ? err.message : String(err); + if (log) { + log.error("IMAGE", `${provider} fetch error: ${message}`); + } + return { + success: false, + status: 504, + error: `Image provider error: ${sanitizeErrorMessage(message || err)}`, + }; + } + throw err; + } if (!response.ok) { const errorText = await response.text(); @@ -2442,14 +2465,15 @@ async function fetchImageEndpoint(url, headers, body, provider, log) { data: data.data || [], }, }; - } catch (err) { + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); if (log) { - log.error("IMAGE", `${provider} fetch error: ${err.message}`); + log.error("IMAGE", `${provider} fetch error: ${message}`); } return { success: false, status: 502, - error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, + error: `Image provider error: ${sanitizeErrorMessage(message || err)}`, }; } } diff --git a/tests/unit/image-generation-fetch-timeout.test.ts b/tests/unit/image-generation-fetch-timeout.test.ts new file mode 100644 index 0000000000..59240e4a63 --- /dev/null +++ b/tests/unit/image-generation-fetch-timeout.test.ts @@ -0,0 +1,81 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { handleImageGeneration } = await import( + "../../open-sse/handlers/imageGeneration.ts" +); + +function restore(fn: () => T): T { + const originalFetch = globalThis.fetch; + try { + return fn(); + } finally { + globalThis.fetch = originalFetch; + } +} + +function makeAbortError() { + return Object.create(Error.prototype, { + message: { value: "The operation was aborted", writable: true, configurable: true }, + name: { value: "AbortError", writable: true, configurable: true }, + }); +} + +test("fetch timeout in OpenAI provider path returns 504 and sanitized error", () => + restore(async () => { + globalThis.fetch = async () => { + throw makeAbortError(); + }; + + const result = await handleImageGeneration({ + body: { model: "openai/gpt-image-2", prompt: "timeout test" }, + credentials: { apiKey: "test-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 504); + assert.match(result.error, /Image provider error:/); + })); + +test("non-timeout fetch error still returns 502", () => + restore(async () => { + globalThis.fetch = async () => { + throw new Error("network down"); + }; + + const result = await handleImageGeneration({ + body: { model: "openai/gpt-image-2", prompt: "network error test" }, + credentials: { apiKey: "test-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 502); + })); + +test("successful image gen passes AbortSignal and returns URL", () => + restore(async () => { + let seenSignal: AbortSignal | null = null; + + globalThis.fetch = async (url, options) => { + seenSignal = (options as RequestInit).signal ?? null; + return new Response( + JSON.stringify({ + created: 999, + data: [{ url: "https://cdn.example.com/timeout-test.png" }], + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + const result = await handleImageGeneration({ + body: { model: "openai/gpt-image-2", prompt: "success test" }, + credentials: { apiKey: "test-key" }, + log: null, + }); + + assert.equal(result.success, true); + assert.ok(seenSignal, "AbortSignal should be passed through fetchWithTimeout"); + assert.equal(result.data.data[0].url, "https://cdn.example.com/timeout-test.png"); + }));