mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 15:20:13 +00:00
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
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user