feat(engine): add opt-in image timeout and scene-paint hedging
IMAGE_TIMEOUT_MS sets a per-attempt hard deadline (AbortSignal.timeout); IMAGE_HEDGE_MS races a second identical scene-paint request when the first is still pending past the threshold. Both default to OFF when unset, preserving historical behavior for self-hosted deploys. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,35 @@
|
||||
type RetryInit = RequestInit & { retries?: number; retryDelayMs?: number };
|
||||
type RetryInit = RequestInit & {
|
||||
retries?: number;
|
||||
retryDelayMs?: number;
|
||||
/**
|
||||
* Per-attempt hard deadline. A timed-out attempt counts as a retryable
|
||||
* failure (it consumes retry budget like a 5xx). Unset → no client-side
|
||||
* timeout, preserving the historical behavior.
|
||||
*/
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export async function fetchWithRetry(
|
||||
url: string,
|
||||
init: RetryInit,
|
||||
): Promise<Response> {
|
||||
const { retries = 2, retryDelayMs = 1500, ...fetchInit } = init;
|
||||
const { retries = 2, retryDelayMs = 1500, timeoutMs, ...fetchInit } = init;
|
||||
if (!fetchInit.redirect) fetchInit.redirect = "manual";
|
||||
// Caller-supplied signal (e.g. a hedge loser being cancelled) must abort
|
||||
// immediately and permanently — it is NOT retryable, unlike our own
|
||||
// per-attempt timeout below.
|
||||
const externalSignal = fetchInit.signal ?? undefined;
|
||||
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
if (externalSignal?.aborted) throw abortError(externalSignal);
|
||||
const attemptSignal = timeoutMs
|
||||
? externalSignal
|
||||
? AbortSignal.any([externalSignal, AbortSignal.timeout(timeoutMs)])
|
||||
: AbortSignal.timeout(timeoutMs)
|
||||
: externalSignal;
|
||||
try {
|
||||
const res = await fetch(url, fetchInit);
|
||||
const res = await fetch(url, { ...fetchInit, signal: attemptSignal });
|
||||
if (res.ok) return res;
|
||||
// Don't retry 4xx (client errors won't fix themselves)
|
||||
if (res.status >= 400 && res.status < 500) return res;
|
||||
@@ -22,9 +41,10 @@ export async function fetchWithRetry(
|
||||
return res;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
const isAbort =
|
||||
err instanceof DOMException && err.name === "AbortError";
|
||||
if (externalSignal?.aborted) throw err;
|
||||
const isAbort = err instanceof DOMException && err.name === "AbortError";
|
||||
if (isAbort) throw err;
|
||||
// TimeoutError (from AbortSignal.timeout) falls through as retryable.
|
||||
if (attempt < retries) {
|
||||
await sleep(retryDelayMs * (attempt + 1));
|
||||
continue;
|
||||
@@ -35,6 +55,12 @@ export async function fetchWithRetry(
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function abortError(signal: AbortSignal): unknown {
|
||||
return signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new DOMException("This operation was aborted", "AbortError");
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
+39
-13
@@ -59,6 +59,15 @@ export type GenerateImageOptions = {
|
||||
* native gpt-image 1024x1536.
|
||||
*/
|
||||
orientation?: Orientation;
|
||||
/**
|
||||
* Per-attempt hard deadline (ms). A timed-out attempt is retryable.
|
||||
* Unset → no client-side timeout (historical behavior).
|
||||
*/
|
||||
timeoutMs?: number;
|
||||
/** Retry-attempt override for this call (default 2). 0 = single attempt. */
|
||||
retries?: number;
|
||||
/** External cancellation, e.g. aborting the losing leg of a hedged race. */
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export type GenerateImageResult = {
|
||||
@@ -143,22 +152,33 @@ async function generateImageOpenAi(
|
||||
const refs = (options?.referenceImages ?? []).slice(0, MAX_REFERENCE_IMAGES);
|
||||
const portrait = options?.orientation === "portrait";
|
||||
const size = portrait ? "1024x1536" : "1536x1024";
|
||||
const requestOptions = {
|
||||
signal: options?.signal,
|
||||
timeout: options?.timeoutMs,
|
||||
...(options?.retries !== undefined ? { maxRetries: options.retries } : {}),
|
||||
};
|
||||
|
||||
const response =
|
||||
refs.length > 0
|
||||
? await client.images.edit({
|
||||
model: config.model,
|
||||
prompt,
|
||||
image: await Promise.all(refs.map(referenceImageToUploadable)),
|
||||
n: 1,
|
||||
size,
|
||||
})
|
||||
: await client.images.generate({
|
||||
model: config.model,
|
||||
prompt,
|
||||
n: 1,
|
||||
size,
|
||||
});
|
||||
? await client.images.edit(
|
||||
{
|
||||
model: config.model,
|
||||
prompt,
|
||||
image: await Promise.all(refs.map(referenceImageToUploadable)),
|
||||
n: 1,
|
||||
size,
|
||||
},
|
||||
requestOptions,
|
||||
)
|
||||
: await client.images.generate(
|
||||
{
|
||||
model: config.model,
|
||||
prompt,
|
||||
n: 1,
|
||||
size,
|
||||
},
|
||||
requestOptions,
|
||||
);
|
||||
|
||||
return imageResponseToResult(response);
|
||||
}
|
||||
@@ -257,6 +277,9 @@ async function generateImageOpenAiCompatible(
|
||||
// Session-locked aspect (16:9 default, 9:16 portrait for mobile).
|
||||
size: options?.orientation === "portrait" ? "1024x1792" : "1792x1024",
|
||||
}),
|
||||
retries: options?.retries,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
@@ -326,6 +349,9 @@ async function generateImageRunware(
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify([task]),
|
||||
retries: options?.retries,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
Reference in New Issue
Block a user