Files
hyperframes/packages/studio/src/utils/studioSaveDiagnostics.ts
T
Miguel Ángel 5f12e692d5 fix(studio): save retries, mutation queue circuit breaker, save_failure diagnostics (#1366)
* fix(studio): save retries, mutation queue circuit breaker, save_failure diagnostics

Save failures could silently drop user work: code-editor saves fired a single
PUT with no retry, DOM-edit failures drained the whole queue against a failing
server, and several failure paths only logged to the console.

- Retry code-editor saves with exponential backoff instead of dropping the
  edit on the first failed PUT.
- Circuit breaker on the DOM-edit save queue: a failing server pauses the
  queue with a user-visible error state instead of burning every queued
  mutation against it.
- save_failure events now carry error_message, status_code, and source on
  every emission path; style/attribute DOM-edit failures that previously only
  logged to the console now emit telemetry too.
- Route unawaited commitMutation call sites (GSAP drag, property scrubbing,
  undo/redo, text fields) through a safe wrapper that reports failures via
  telemetry instead of unhandledrejection.

Follow-ups (deferred): version/ETag conflict guard on file PUTs, offline
save queue.

* fix(studio): narrow save retry changes for fallow
2026-06-12 22:02:17 -04:00

201 lines
6.8 KiB
TypeScript

import { trackStudioEvent } from "./studioTelemetry";
type StudioTelemetryValue = string | number | boolean | null | undefined;
const STUDIO_SAVE_ATTEMPT_PROPERTY = "__studioSaveAttempt";
export interface StudioSaveFailureInput {
source: string;
error: unknown;
statusCode?: number | null;
filePath?: string | null;
mutationType?: string | null;
attempt?: number | null;
label?: string | null;
targetId?: string | null;
targetSelector?: string | null;
targetSourceFile?: string | null;
}
export class StudioSaveHttpError extends Error {
readonly statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.name = "StudioSaveHttpError";
this.statusCode = statusCode;
}
}
export class StudioSaveNetworkError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
super(message, options);
this.name = "StudioSaveNetworkError";
}
}
function readNumericProperty(value: object, key: string): number | undefined {
const record = value as Record<string, unknown>;
const property = record[key];
return typeof property === "number" && Number.isFinite(property) ? property : undefined;
}
function createStudioSaveAbortError(): Error {
if (typeof DOMException !== "undefined") return new DOMException("Save aborted", "AbortError");
const error = new Error("Save aborted");
error.name = "AbortError";
return error;
}
function throwIfStudioSaveAborted(signal?: AbortSignal): void {
if (signal?.aborted) throw createStudioSaveAbortError();
}
function attachStudioSaveAttempt(error: unknown, attempt: number): unknown {
if (!error || typeof error !== "object") return error;
try {
Object.defineProperty(error, STUDIO_SAVE_ATTEMPT_PROPERTY, {
value: attempt,
configurable: true,
});
} catch {
// Best-effort diagnostic only.
}
return error;
}
export function getStudioSaveErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
if (typeof error === "string" && error.trim()) return error;
return "Unknown save failure";
}
export function getStudioSaveStatusCode(error: unknown): number | undefined {
if (!error || typeof error !== "object") return undefined;
const direct =
readNumericProperty(error, "statusCode") ??
readNumericProperty(error, "status") ??
readNumericProperty(error, "status_code");
if (direct != null) return direct;
const cause = (error as { cause?: unknown }).cause;
if (cause && cause !== error) return getStudioSaveStatusCode(cause);
return undefined;
}
function getStudioSaveAttempt(error: unknown): number | undefined {
if (!error || typeof error !== "object") return undefined;
const direct = readNumericProperty(error, STUDIO_SAVE_ATTEMPT_PROPERTY);
if (direct != null) return direct;
const cause = (error as { cause?: unknown }).cause;
if (cause && cause !== error) return getStudioSaveAttempt(cause);
return undefined;
}
function isStudioSaveAbortError(error: unknown): boolean {
return error instanceof Error && error.name === "AbortError";
}
function isRetryableStudioSaveError(error: unknown): boolean {
if (isStudioSaveAbortError(error)) return false;
if (error instanceof StudioSaveNetworkError) return true;
const statusCode = getStudioSaveStatusCode(error);
if (statusCode == null) return false;
return statusCode === 408 || statusCode === 425 || statusCode === 429 || statusCode >= 500;
}
export function buildStudioSaveFailureProperties(
input: StudioSaveFailureInput,
): Record<string, StudioTelemetryValue> {
const statusCode = input.statusCode ?? getStudioSaveStatusCode(input.error) ?? null;
const attempt = input.attempt ?? getStudioSaveAttempt(input.error) ?? undefined;
return {
source: input.source,
error_message: getStudioSaveErrorMessage(input.error),
status_code: statusCode,
file_path: input.filePath ?? input.targetSourceFile ?? undefined,
mutation_type: input.mutationType ?? undefined,
attempt,
label: input.label ?? undefined,
target_id: input.targetId ?? undefined,
target_selector: input.targetSelector ?? undefined,
target_source_file: input.targetSourceFile ?? undefined,
};
}
export function trackStudioSaveFailure(input: StudioSaveFailureInput): void {
trackStudioEvent("save_failure", buildStudioSaveFailureProperties(input));
}
export async function createStudioSaveHttpError(
response: Response,
fallbackMessage: string,
): Promise<StudioSaveHttpError> {
let body = "";
try {
body = await response.text();
} catch {
body = "";
}
const detail = body.trim().slice(0, 300);
const message = detail
? `${fallbackMessage} (${response.status}): ${detail}`
: `${fallbackMessage} (${response.status})`;
return new StudioSaveHttpError(message, response.status);
}
export async function retryStudioSave<T>(
operation: (attempt: number) => Promise<T>,
options: {
retries?: number;
baseDelayMs?: number;
maxDelayMs?: number;
jitterRatio?: number;
random?: () => number;
signal?: AbortSignal;
shouldRetry?: (error: unknown, attempt: number) => boolean;
sleep?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
} = {},
): Promise<T> {
const retries = options.retries ?? 3;
const baseDelayMs = options.baseDelayMs ?? 500;
const maxDelayMs = options.maxDelayMs ?? 8000;
const jitterRatio = options.jitterRatio ?? 0.25;
const random = options.random ?? Math.random;
const shouldRetry = options.shouldRetry ?? isRetryableStudioSaveError;
const sleep =
options.sleep ??
((delayMs: number, signal?: AbortSignal) =>
new Promise<void>((resolve, reject) => {
throwIfStudioSaveAborted(signal);
const onAbort = () => {
globalThis.clearTimeout(timeout);
reject(createStudioSaveAbortError());
};
const timeout = globalThis.setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, delayMs);
signal?.addEventListener("abort", onAbort, { once: true });
}));
const maxAttempts = retries + 1;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
throwIfStudioSaveAborted(options.signal);
return await operation(attempt);
} catch (error) {
const failure = attachStudioSaveAttempt(error, attempt);
if (attempt >= maxAttempts || !shouldRetry(failure, attempt)) throw failure;
const retryIndex = attempt - 1;
const exponentialDelay = Math.min(baseDelayMs * 2 ** retryIndex, maxDelayMs);
const jitterSpan = exponentialDelay * jitterRatio;
const jitteredDelay = Math.round(exponentialDelay + (random() * 2 - 1) * jitterSpan);
const delayMs = Math.max(0, Math.min(maxDelayMs, jitteredDelay));
await sleep(delayMs, options.signal);
}
}
throw new Error("Save retry loop exited unexpectedly");
}