Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions components/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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;

Expand Down
16 changes: 8 additions & 8 deletions components/app/ProcessCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,8 @@ export function ProcessCanvas({
<h2 className={styles.title}>{heading}</h2>
<p className={styles.message}>{message}</p>
<div className={styles.headerMeta} aria-label="Current processing context">
<span>{currentPhase.title}</span>
<span>{currentPhase.description}</span>
<span>{stage === "analyzing" ? "Model response" : currentPhase.title}</span>
<span>{stage === "analyzing" ? "Waiting for the analysis service to return code." : currentPhase.description}</span>
</div>
</div>

Expand All @@ -205,7 +205,7 @@ export function ProcessCanvas({
<div className={styles.progressTopline}>
<span className={styles.progressValue}>{Math.round(safeProgress)}%</span>
<span className={styles.progressPhase}>
{completedPhases}/{PHASES.length} phases
{stage === "analyzing" ? "Estimated progress" : `${completedPhases}/${PHASES.length} phases`}
</span>
</div>
<span className={styles.progressTrack}>
Expand All @@ -214,7 +214,7 @@ export function ProcessCanvas({
style={{ "--progress": `${safeProgress}%` } as StyleVars}
/>
</span>
<span className={styles.progressCaption}>{getProgressCaption(stage, safeActiveStep)}</span>
<span className={styles.progressCaption}>{getProgressCaption(stage)}</span>
</div>
</header>

Expand All @@ -233,9 +233,9 @@ export function ProcessCanvas({
/>

<div className={styles.stageOverlay}>
<span className={styles.stageFormation}>{getFormationLabel(fieldProgress)}</span>
<span className={styles.stageFormation}>{stage === "analyzing" ? "analysis · awaiting response" : getFormationLabel(fieldProgress)}</span>
<span className={styles.stageReadout}>
{currentPhase.metric} · {getPhaseLabel(currentPhase.key, stage, safeActiveStep)}
{stage === "analyzing" ? "model · pending" : `${currentPhase.metric} · ${getPhaseLabel(currentPhase.key, stage, safeActiveStep)}`}
</span>
</div>

Expand Down Expand Up @@ -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":
Expand Down
34 changes: 34 additions & 0 deletions lib/analysisDeadline.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
operation: (signal: AbortSignal) => Promise<T>,
signal?: AbortSignal,
): Promise<T> {
const controller = new AbortController();
if (signal?.aborted) {
throw new DOMException("Analysis cancelled.", "AbortError");
}

let timer: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
const interrupted = new Promise<never>((_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);
}
}
54 changes: 52 additions & 2 deletions lib/server/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,11 +203,12 @@ export async function analyzeFramesWithGemini(
): Promise<GeminiAnalysis> {
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.
Expand All @@ -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<GeminiAnalysis> {
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<void>((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<GeminiAnalysis> {
const response = await fetcher(
`${GEMINI_ENDPOINT_ROOT}/${upstreamModel}:generateContent`,
Expand Down Expand Up @@ -257,6 +303,7 @@ async function requestGeminiAnalysis(
"x-goog-api-key": apiKey,
},
method: "POST",
signal,
},
);

Expand All @@ -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.");
}

Expand Down
46 changes: 46 additions & 0 deletions tests/unit/analysis-deadline.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
86 changes: 86 additions & 0 deletions tests/unit/gemini-retries.test.ts
Original file line number Diff line number Diff line change
@@ -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<Response>((_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:"),
]);
});
});
17 changes: 17 additions & 0 deletions tests/unit/process-canvas-waiting.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ProcessCanvas stage="analyzing" activeStep={10} progressWidth={94} />,
);
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");
});
Loading