fix(design/variants): surface real 240s timeout in AbortError message

`generateVariant` in `design/src/variants.ts` arms its abort timer
at 240_000 ms (`setTimeout(() => controller.abort(), 240_000)` at
line 61), but the AbortError branch at line 128 returns
`"Timeout (120s)"` — half the actual bound.

The mismatch is purely a stale string literal, but it's the only
signal a caller gets when the API stalls. A user staring at
`Timeout (120s)` has no way to know whether to bump the orchestrator
timeout, retry, or drop the call — the surfaced number is off by 2x
from the timer that fired.

Surface the real bound: "Timeout (240s)". Adds a regression test that
stubs fetch to a permanently-pending promise, fast-forwards only the
240_000 ms abort timer (leaves the leading exponential retry delays
on their real values), and asserts the surfaced error string matches
the configured bound.
This commit is contained in:
Pablosinyores 2026-05-28 19:11:51 +05:30
parent 19770ea8b4
commit 5e5037b621
2 changed files with 53 additions and 1 deletions

View File

@ -125,7 +125,7 @@ export async function generateVariant(
} catch (err: any) {
clearTimeout(timeout);
if (err.name === "AbortError") {
return { path: outputPath, success: false, error: "Timeout (120s)" };
return { path: outputPath, success: false, error: "Timeout (240s)" };
}
lastError = err.message;
}

View File

@ -130,4 +130,56 @@ describe("generateVariant Retry-After handling", () => {
const gap = calls[1].ts - calls[0].ts;
expect(gap).toBeLessThan(500);
});
test("AbortError surfaces the actual configured 240s timeout in the error message", async () => {
// Regression: `generateVariant`'s `setTimeout` aborts at 240_000 ms
// (240s) but the AbortError branch returned `"Timeout (120s)"`. A
// user staring at the failure has no way to know whether to bump
// the orchestrator timeout, retry, or drop the call — the message
// is off by 2x. Force the abort path and assert the surfaced
// string matches the real bound.
const fetchFn = (async (_input: any, init?: any): Promise<Response> => {
const signal = init?.signal as AbortSignal | undefined;
return await new Promise((_resolve, reject) => {
if (signal?.aborted) {
const err = new Error("aborted");
err.name = "AbortError";
reject(err);
return;
}
signal?.addEventListener("abort", () => {
const err = new Error("aborted");
err.name = "AbortError";
reject(err);
});
});
}) as typeof globalThis.fetch;
const originalSetTimeout = globalThis.setTimeout;
// Force the 240_000 ms timer to fire on the next event-loop tick
// so the test runs in milliseconds instead of 4 minutes. Only the
// 240_000 ms timer maps to fast; the leading exponential delays
// (2_000+ ms on retry) keep their real value via this branch
// because attempt 0 never sleeps.
const fastSetTimeout = ((handler: any, timeout?: number, ...rest: any[]): any => {
if (timeout === 240_000) {
return originalSetTimeout(handler, 0, ...rest);
}
return originalSetTimeout(handler, timeout as number, ...rest);
}) as typeof globalThis.setTimeout;
(globalThis as any).setTimeout = fastSetTimeout;
try {
const result = await generateVariant(
"fake-key", "prompt", outputPath, "1024x1024", "high", fetchFn,
);
expect(result.success).toBe(false);
// Critical: the message MUST report 240s (the real bound), not
// 120s (the pre-fix mismatched literal).
expect(result.error).toBe("Timeout (240s)");
} finally {
(globalThis as any).setTimeout = originalSetTimeout;
}
});
});