Browse Source
Shared polling utility that calls /api/generate/poll with short-lived requests (3-8s interval with linear backoff). Handles abort signals, network error retries, and 10-minute client-side timeout. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>handoff-20260429-1057
1 changed files with 140 additions and 0 deletions
@ -0,0 +1,140 @@ |
|||
/** |
|||
* Client-Side Task Polling |
|||
* |
|||
* Shared utility for polling /api/generate/poll until a long-running |
|||
* task completes. Each poll is a short-lived HTTP request (~1s), |
|||
* making the system resilient to connection idle timeouts. |
|||
*/ |
|||
|
|||
import type { GenerateResponse } from "@/types"; |
|||
|
|||
export interface PollGenerateTaskOptions { |
|||
taskId: string; |
|||
provider: string; |
|||
modelId: string; |
|||
modelName: string; |
|||
mediaType: string; |
|||
headers: Record<string, string>; |
|||
signal?: AbortSignal; |
|||
} |
|||
|
|||
const INITIAL_INTERVAL = 3000; // 3s
|
|||
const MAX_INTERVAL = 8000; // 8s
|
|||
const INTERVAL_STEP = 500; // grow by 500ms each poll
|
|||
const MAX_POLL_TIME = 10 * 60 * 1000; // 10 minutes
|
|||
const MAX_CONSECUTIVE_ERRORS = 10; |
|||
|
|||
/** |
|||
* Poll /api/generate/poll until the task completes, fails, or times out. |
|||
* Returns the final GenerateResponse with media data. |
|||
*/ |
|||
export async function pollGenerateTask( |
|||
options: PollGenerateTaskOptions |
|||
): Promise<GenerateResponse> { |
|||
const { taskId, provider, modelId, modelName, mediaType, headers, signal } = options; |
|||
|
|||
const startTime = Date.now(); |
|||
let interval = INITIAL_INTERVAL; |
|||
let consecutiveErrors = 0; |
|||
|
|||
while (true) { |
|||
// Check client-side timeout
|
|||
if (Date.now() - startTime > MAX_POLL_TIME) { |
|||
return { success: false, error: `${modelName}: Generation timed out after 10 minutes` }; |
|||
} |
|||
|
|||
// Check abort signal
|
|||
if (signal?.aborted) { |
|||
throw new DOMException("The operation was aborted.", "AbortError"); |
|||
} |
|||
|
|||
// Wait before polling
|
|||
await new Promise<void>((resolve, reject) => { |
|||
const timer = setTimeout(resolve, interval); |
|||
if (signal) { |
|||
const onAbort = () => { |
|||
clearTimeout(timer); |
|||
reject(new DOMException("The operation was aborted.", "AbortError")); |
|||
}; |
|||
signal.addEventListener("abort", onAbort, { once: true }); |
|||
// Clean up listener when timer fires
|
|||
const origResolve = resolve; |
|||
resolve = () => { |
|||
signal.removeEventListener("abort", onAbort); |
|||
origResolve(); |
|||
}; |
|||
} |
|||
}); |
|||
|
|||
// Make poll request
|
|||
let response: Response; |
|||
try { |
|||
response = await fetch("/api/generate/poll", { |
|||
method: "POST", |
|||
headers, |
|||
body: JSON.stringify({ taskId, provider, modelId, modelName, mediaType }), |
|||
...(signal ? { signal } : {}), |
|||
}); |
|||
} catch (error) { |
|||
// Network errors during individual polls are recoverable
|
|||
if (error instanceof DOMException && error.name === "AbortError") { |
|||
throw error; |
|||
} |
|||
consecutiveErrors++; |
|||
console.warn(`[poll] Network error (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}):`, error); |
|||
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { |
|||
return { success: false, error: `${modelName}: Polling failed after ${MAX_CONSECUTIVE_ERRORS} consecutive network errors` }; |
|||
} |
|||
interval = Math.min(interval + INTERVAL_STEP, MAX_INTERVAL); |
|||
continue; |
|||
} |
|||
|
|||
if (!response.ok) { |
|||
// Server errors during polling may be transient
|
|||
if (response.status >= 500) { |
|||
consecutiveErrors++; |
|||
console.warn(`[poll] Server error ${response.status} (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS})`); |
|||
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { |
|||
const errorText = await response.text().catch(() => ""); |
|||
return { success: false, error: `${modelName}: Polling failed - ${errorText || `HTTP ${response.status}`}` }; |
|||
} |
|||
interval = Math.min(interval + INTERVAL_STEP, MAX_INTERVAL); |
|||
continue; |
|||
} |
|||
|
|||
// 4xx errors are not recoverable
|
|||
const errorText = await response.text().catch(() => ""); |
|||
let errorMessage = `HTTP ${response.status}`; |
|||
try { |
|||
const errorJson = JSON.parse(errorText); |
|||
errorMessage = errorJson.error || errorMessage; |
|||
} catch { |
|||
if (errorText) errorMessage += ` - ${errorText.substring(0, 200)}`; |
|||
} |
|||
return { success: false, error: errorMessage }; |
|||
} |
|||
|
|||
let result: GenerateResponse; |
|||
try { |
|||
result = await response.json(); |
|||
} catch { |
|||
consecutiveErrors++; |
|||
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { |
|||
return { success: false, error: `${modelName}: Polling failed - invalid response` }; |
|||
} |
|||
continue; |
|||
} |
|||
|
|||
// Reset on successful response
|
|||
consecutiveErrors = 0; |
|||
|
|||
// Still polling — task not done yet
|
|||
if (result.polling) { |
|||
interval = Math.min(interval + INTERVAL_STEP, MAX_INTERVAL); |
|||
continue; |
|||
} |
|||
|
|||
// Task completed (success or failure) — return final result
|
|||
return result; |
|||
} |
|||
} |
|||
Loading…
Reference in new issue