diff --git a/components/app/AppShell.tsx b/components/app/AppShell.tsx index fcdf568..1a3c0e7 100644 --- a/components/app/AppShell.tsx +++ b/components/app/AppShell.tsx @@ -12,6 +12,8 @@ import dynamic from "next/dynamic"; import Link from "next/link"; import { useRouter } from "next/navigation"; +import { withAnalysisDeadline } from "@/lib/analysisDeadline"; + import { AppStatusBar } from "@/components/app/AppStatusBar"; import { DEFAULT_INTENT_COLOR, @@ -61,10 +63,10 @@ const FREE_SAVE_ACK_SESSION_KEY = "motioncode_free_save_ack_session"; const FREE_SAVE_ACK_DISMISSED_KEY = "motioncode_free_save_ack_dismissed"; const STATUS_MESSAGES = [ - "Analyzing motion patterns...", - "Reading easing curves...", - "Detecting transform paths...", - "Almost there...", + "Waiting for the analysis service...", + "The model is still processing your frames...", + "Your frames are ready; waiting for generated code...", + "Still waiting. You can cancel and retry.", ]; type SaveState = @@ -419,11 +421,10 @@ export function AppShell({ setStatusMessage("Analyzing with server model..."); try { - const analyzed = await analyzeViaApi({ - frames, - planTier: userPlan, - signal: controller.signal, - }); + const analyzed = await withAnalysisDeadline( + (signal) => analyzeViaApi({ frames, planTier: userPlan, signal }), + controller.signal, + ); if (operationId !== operationIdRef.current) return; diff --git a/components/app/ProcessCanvas.tsx b/components/app/ProcessCanvas.tsx index 1a34371..92f20b3 100644 --- a/components/app/ProcessCanvas.tsx +++ b/components/app/ProcessCanvas.tsx @@ -189,8 +189,8 @@ export function ProcessCanvas({

{heading}

{message}

- {currentPhase.title} - {currentPhase.description} + {stage === "analyzing" ? "Model response" : currentPhase.title} + {stage === "analyzing" ? "Waiting for the analysis service to return code." : currentPhase.description}
@@ -205,7 +205,7 @@ export function ProcessCanvas({
{Math.round(safeProgress)}% - {completedPhases}/{PHASES.length} phases + {stage === "analyzing" ? "Estimated progress" : `${completedPhases}/${PHASES.length} phases`}
@@ -214,7 +214,7 @@ export function ProcessCanvas({ style={{ "--progress": `${safeProgress}%` } as StyleVars} /> - {getProgressCaption(stage, safeActiveStep)} + {getProgressCaption(stage)} @@ -233,9 +233,9 @@ export function ProcessCanvas({ />
- {getFormationLabel(fieldProgress)} + {stage === "analyzing" ? "analysis · awaiting response" : getFormationLabel(fieldProgress)} - {currentPhase.metric} · {getPhaseLabel(currentPhase.key, stage, safeActiveStep)} + {stage === "analyzing" ? "model · pending" : `${currentPhase.metric} · ${getPhaseLabel(currentPhase.key, stage, safeActiveStep)}`}
@@ -392,12 +392,12 @@ function getCurrentPhase(stage: AnalysisStage, activeStep: number) { ); } -function getProgressCaption(stage: AnalysisStage, activeStep: number) { +function getProgressCaption(stage: AnalysisStage) { switch (stage) { case "extracting": return "Frame buffer warming"; case "analyzing": - return `Step ${activeStep + 1} in progress`; + return "Waiting for model response"; case "error": return "Interrupted"; case "idle": diff --git a/lib/analysisDeadline.ts b/lib/analysisDeadline.ts new file mode 100644 index 0000000..3d05cbf --- /dev/null +++ b/lib/analysisDeadline.ts @@ -0,0 +1,34 @@ +// The browser deadline includes frame preparation, the API call, and body reads. +// Leave time beyond the server's 50-second model budget for auth and transport. +export const ANALYSIS_TIMEOUT_MS = 65_000; + +export async function withAnalysisDeadline( + operation: (signal: AbortSignal) => Promise, + signal?: AbortSignal, +): Promise { + const controller = new AbortController(); + if (signal?.aborted) { + throw new DOMException("Analysis cancelled.", "AbortError"); + } + + let timer: ReturnType | undefined; + let onAbort: (() => void) | undefined; + const interrupted = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + reject(new Error("Analysis took too long. Please retry; your frames are still ready.")); + controller.abort(); + }, ANALYSIS_TIMEOUT_MS); + onAbort = () => { + reject(new DOMException("Analysis cancelled.", "AbortError")); + controller.abort(); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); + + try { + return await Promise.race([operation(controller.signal), interrupted]); + } finally { + clearTimeout(timer); + if (onAbort) signal?.removeEventListener("abort", onAbort); + } +} diff --git a/lib/server/gemini.ts b/lib/server/gemini.ts index 17839ce..3645aaf 100644 --- a/lib/server/gemini.ts +++ b/lib/server/gemini.ts @@ -203,11 +203,12 @@ export async function analyzeFramesWithGemini( ): Promise { const apiKey = deps.apiKey ?? getServerEnv().geminiApiKey; const fetcher = deps.fetch ?? fetch; + const deadline = Date.now() + 50_000; const primary = resolveUpstreamGeminiModel(input.model); const fallback = resolveUpstreamGeminiModel("gemini-2.5-flash"); try { - return await requestGeminiAnalysis(input, primary, apiKey, fetcher); + return await requestGeminiWithRetries(input, primary, apiKey, fetcher, deadline); } catch (error) { // Pro models are often unavailable to a key (preview access, free-tier // quota); answer with Flash rather than failing the paid analysis. @@ -220,15 +221,60 @@ export async function analyzeFramesWithGemini( } serverLogger.warn("gemini.model_fallback", { fallback, model: primary }); - return requestGeminiAnalysis(input, fallback, apiKey, fetcher); + return requestGeminiWithRetries(input, fallback, apiKey, fetcher, deadline); } } +const RETRYABLE_STATUSES = new Set([500, 502, 503, 504]); +class RetryableGeminiError extends ApiError {} + +const TEMPORARY_FAILURE_MESSAGE = + "Analysis service is temporarily busy. Please try again shortly."; + +async function requestGeminiWithRetries( + input: GeminiAnalyzeInput, + model: string, + apiKey: string, + fetcher: typeof fetch, + deadline: number, +): Promise { + for (let attempt = 0; attempt < 3; attempt += 1) { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + break; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), Math.min(15_000, remainingMs)); + try { + return await requestGeminiAnalysis(input, model, apiKey, fetcher, controller.signal); + } catch (error) { + const retryable = controller.signal.aborted || error instanceof TypeError || + error instanceof RetryableGeminiError; + if (!retryable) { + throw error; + } + } finally { + clearTimeout(timeout); + } + + const delayMs = 1_000 * 2 ** attempt + Math.floor(Math.random() * 250); + if (attempt === 2 || Date.now() + delayMs >= deadline) { + break; + } + serverLogger.warn("gemini.request_retry", { attempt: attempt + 1, delayMs, model }); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + + throw new ApiError("MODEL_FAILED", TEMPORARY_FAILURE_MESSAGE, 503); +} + async function requestGeminiAnalysis( input: GeminiAnalyzeInput, upstreamModel: string, apiKey: string, fetcher: typeof fetch, + signal: AbortSignal, ): Promise { const response = await fetcher( `${GEMINI_ENDPOINT_ROOT}/${upstreamModel}:generateContent`, @@ -257,6 +303,7 @@ async function requestGeminiAnalysis( "x-goog-api-key": apiKey, }, method: "POST", + signal, }, ); @@ -276,6 +323,9 @@ async function requestGeminiAnalysis( ); } + if (RETRYABLE_STATUSES.has(response.status)) { + throw new RetryableGeminiError("MODEL_FAILED", TEMPORARY_FAILURE_MESSAGE, response.status); + } throw new ApiError("MODEL_FAILED", "Gemini analysis failed."); } diff --git a/tests/unit/analysis-deadline.test.ts b/tests/unit/analysis-deadline.test.ts new file mode 100644 index 0000000..acb8291 --- /dev/null +++ b/tests/unit/analysis-deadline.test.ts @@ -0,0 +1,46 @@ +// @vitest-environment node +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { ANALYSIS_TIMEOUT_MS, withAnalysisDeadline } from "@/lib/analysisDeadline"; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +it("ends a stalled operation and aborts the underlying request", async () => { + let requestSignal: AbortSignal | undefined; + const result = withAnalysisDeadline((signal) => { + requestSignal = signal; + return new Promise(() => {}); + }); + const assertion = expect(result).rejects.toThrow("Analysis took too long"); + await vi.advanceTimersByTimeAsync(ANALYSIS_TIMEOUT_MS); + await assertion; + expect(requestSignal?.aborted).toBe(true); + expect(vi.getTimerCount()).toBe(0); +}); + +it("preserves explicit cancellation even if the operation never settles", async () => { + const controller = new AbortController(); + const result = withAnalysisDeadline(() => new Promise(() => {}), controller.signal); + const assertion = expect(result).rejects.toMatchObject({ name: "AbortError" }); + controller.abort(); + await assertion; + expect(vi.getTimerCount()).toBe(0); +}); + +it("does not start an already-cancelled operation", async () => { + const controller = new AbortController(); + controller.abort(); + const operation = vi.fn(); + await expect(withAnalysisDeadline(operation, controller.signal)).rejects.toMatchObject({ name: "AbortError" }); + expect(operation).not.toHaveBeenCalled(); +}); + +it("cleans up the deadline after successful analysis", async () => { + await expect(withAnalysisDeadline(async () => "code")).resolves.toBe("code"); + expect(vi.getTimerCount()).toBe(0); +}); + +it("preserves server errors and cleans up the deadline", async () => { + await expect(withAnalysisDeadline(async () => { throw new Error("Model busy"); })).rejects.toThrow("Model busy"); + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/tests/unit/gemini-retries.test.ts b/tests/unit/gemini-retries.test.ts new file mode 100644 index 0000000..1ffed93 --- /dev/null +++ b/tests/unit/gemini-retries.test.ts @@ -0,0 +1,86 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { analyzeFramesWithGemini } from "@/lib/server/gemini"; + +const input = { frames: ["jpeg"], model: "gemini-2.5-flash" as const }; +const success = () => Response.json({ + candidates: [{ content: { parts: [{ text: JSON.stringify({ intent: "entrance" }) }] } }], +}); +const busy = () => Response.json({ error: { message: "high demand" } }, { status: 503 }); +const run = (fetcher: typeof fetch, model = input.model as "gemini-2.5-flash" | "gemini-2.5-pro") => + analyzeFramesWithGemini({ ...input, model }, { apiKey: "test-key", fetch: fetcher }); + +beforeEach(() => { + vi.useFakeTimers(); + vi.stubEnv("GEMINI_FLASH_MODEL", "test-flash"); + vi.stubEnv("GEMINI_PRO_MODEL", "test-pro"); +}); +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); +}); + +describe("Gemini transient failures", () => { + it("recovers from an overloaded model with delayed retries", async () => { + const fetcher = vi.fn().mockResolvedValueOnce(busy()).mockResolvedValueOnce(busy()) + .mockImplementation(async () => success()); + const result = run(fetcher); + const assertion = expect(result).resolves.toMatchObject({ spec: { intent: "entrance" } }); + await vi.advanceTimersByTimeAsync(999); + expect(fetcher).toHaveBeenCalledTimes(1); + await vi.runAllTimersAsync(); + await assertion; + expect(fetcher).toHaveBeenCalledTimes(3); + expect(vi.getTimerCount()).toBe(0); + }); + + it("stops after three failures and returns a temporary-unavailability error", async () => { + const fetcher = vi.fn(async () => busy()); + const assertion = expect(run(fetcher)).rejects.toMatchObject({ + code: "MODEL_FAILED", status: 503, message: expect.stringContaining("temporarily busy"), + }); + await vi.runAllTimersAsync(); + await assertion; + expect(fetcher).toHaveBeenCalledTimes(3); + }); + + it.each([400, 401, 403, 404, 429])("does not retry HTTP %s for Flash", async (status) => { + const fetcher = vi.fn(async () => Response.json({ error: { message: "rejected" } }, { status })); + await expect(run(fetcher)).rejects.toMatchObject({ code: status === 429 ? "QUOTA_EXCEEDED" : "MODEL_FAILED" }); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + + it("retries a failed network connection", async () => { + const fetcher = vi.fn().mockRejectedValueOnce(new TypeError("fetch failed")) + .mockImplementation(async () => success()); + const assertion = expect(run(fetcher)).resolves.toHaveProperty("spec"); + await vi.runAllTimersAsync(); + await assertion; + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it("aborts stalled requests and bounds primary plus fallback to 50 seconds", async () => { + const started = Date.now(); + const fetcher = vi.fn((_url: unknown, options?: RequestInit) => new Promise((_resolve, reject) => { + options?.signal?.addEventListener("abort", () => reject(new DOMException("Timed out", "AbortError")), { once: true }); + })); + const assertion = expect(run(fetcher, "gemini-2.5-pro")).rejects.toMatchObject({ code: "MODEL_FAILED", status: 503 }); + await vi.runAllTimersAsync(); + await assertion; + expect(Date.now() - started).toBeLessThanOrEqual(50_000); + expect(fetcher).toHaveBeenCalledTimes(4); + expect(vi.getTimerCount()).toBe(0); + }); + + it("retains the Pro-to-Flash fallback after temporary failures", async () => { + const fetcher = vi.fn().mockResolvedValueOnce(busy()).mockResolvedValueOnce(busy()) + .mockResolvedValueOnce(busy()).mockImplementation(async () => success()); + const assertion = expect(run(fetcher, "gemini-2.5-pro")).resolves.toHaveProperty("spec"); + await vi.runAllTimersAsync(); + await assertion; + expect(fetcher.mock.calls.map(([url]) => url)).toEqual([ + ...Array(3).fill(expect.stringContaining("/test-pro:")), expect.stringContaining("/test-flash:"), + ]); + }); +}); diff --git a/tests/unit/process-canvas-waiting.test.tsx b/tests/unit/process-canvas-waiting.test.tsx new file mode 100644 index 0000000..555ce1d --- /dev/null +++ b/tests/unit/process-canvas-waiting.test.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { expect, it, vi } from "vitest"; + +vi.mock("next/dynamic", () => ({ default: () => () => null })); +import { ProcessCanvas } from "@/components/app/ProcessCanvas"; + +it("labels estimated progress as waiting rather than completed compilation", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Estimated progress"); + expect(html).toContain("Waiting for model response"); + expect(html).toContain("analysis · awaiting response"); + expect(html).not.toContain("compiled · stable"); + expect(html).not.toContain("6/6 phases"); +});