Browse Source

feat: add client-side pollGenerateTask utility

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
shrimbly 3 months ago
parent
commit
f3d8724759
  1. 140
      src/store/execution/pollTaskCompletion.ts

140
src/store/execution/pollTaskCompletion.ts

@ -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…
Cancel
Save