mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +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,127 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
StudioSaveHttpError,
|
||||
StudioSaveNetworkError,
|
||||
buildStudioSaveFailureProperties,
|
||||
getStudioSaveStatusCode,
|
||||
retryStudioSave,
|
||||
} from "./studioSaveDiagnostics";
|
||||
|
||||
describe("studio save diagnostics", () => {
|
||||
it("builds save_failure properties with stable diagnostics", () => {
|
||||
const error = new StudioSaveHttpError("Failed to save index.html (503)", 503);
|
||||
|
||||
expect(
|
||||
buildStudioSaveFailureProperties({
|
||||
source: "code_editor",
|
||||
error,
|
||||
filePath: "index.html",
|
||||
mutationType: "put",
|
||||
attempt: 3,
|
||||
}),
|
||||
).toEqual({
|
||||
source: "code_editor",
|
||||
error_message: "Failed to save index.html (503)",
|
||||
status_code: 503,
|
||||
file_path: "index.html",
|
||||
mutation_type: "put",
|
||||
attempt: 3,
|
||||
label: undefined,
|
||||
target_id: undefined,
|
||||
target_selector: undefined,
|
||||
target_source_file: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads nested status codes from error causes", () => {
|
||||
const cause = new StudioSaveHttpError("Too many requests", 429);
|
||||
const error = new Error("retry wrapper") as Error & { cause?: unknown };
|
||||
error.cause = cause;
|
||||
|
||||
expect(getStudioSaveStatusCode(error)).toBe(429);
|
||||
});
|
||||
|
||||
it("retries transient save failures with exponential backoff and jitter", async () => {
|
||||
const sleeps: number[] = [];
|
||||
const operation = vi
|
||||
.fn<(attempt: number) => Promise<string>>()
|
||||
.mockRejectedValueOnce(new StudioSaveHttpError("Server restarting", 503))
|
||||
.mockRejectedValueOnce(new StudioSaveHttpError("Still restarting", 503))
|
||||
.mockRejectedValueOnce(new StudioSaveHttpError("Almost ready", 503))
|
||||
.mockResolvedValue("saved");
|
||||
|
||||
await expect(
|
||||
retryStudioSave(operation, {
|
||||
random: () => 0.5,
|
||||
sleep: async (delayMs) => {
|
||||
sleeps.push(delayMs);
|
||||
},
|
||||
}),
|
||||
).resolves.toBe("saved");
|
||||
|
||||
expect(operation).toHaveBeenCalledTimes(4);
|
||||
expect(operation.mock.calls.map(([attempt]) => attempt)).toEqual([1, 2, 3, 4]);
|
||||
expect(sleeps).toEqual([500, 1000, 2000]);
|
||||
});
|
||||
|
||||
it("does not retry non-transient client failures", async () => {
|
||||
const operation = vi
|
||||
.fn<(attempt: number) => Promise<string>>()
|
||||
.mockRejectedValue(new StudioSaveHttpError("Too large", 413));
|
||||
|
||||
await expect(
|
||||
retryStudioSave(operation, {
|
||||
sleep: async () => {},
|
||||
}),
|
||||
).rejects.toThrow("Too large");
|
||||
|
||||
expect(operation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries typed network failures", async () => {
|
||||
const operation = vi
|
||||
.fn<(attempt: number) => Promise<string>>()
|
||||
.mockRejectedValueOnce(new StudioSaveNetworkError("network dropped"))
|
||||
.mockResolvedValue("saved");
|
||||
|
||||
await expect(
|
||||
retryStudioSave(operation, {
|
||||
sleep: async () => {},
|
||||
}),
|
||||
).resolves.toBe("saved");
|
||||
|
||||
expect(operation).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not retry plain JavaScript errors", async () => {
|
||||
const operation = vi
|
||||
.fn<(attempt: number) => Promise<string>>()
|
||||
.mockRejectedValue(new Error("local assertion failed"));
|
||||
|
||||
await expect(
|
||||
retryStudioSave(operation, {
|
||||
sleep: async () => {},
|
||||
}),
|
||||
).rejects.toThrow("local assertion failed");
|
||||
|
||||
expect(operation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("aborts while waiting between retry attempts", async () => {
|
||||
const controller = new AbortController();
|
||||
const operation = vi
|
||||
.fn<(attempt: number) => Promise<string>>()
|
||||
.mockRejectedValue(new StudioSaveHttpError("Server restarting", 503));
|
||||
|
||||
const pending = retryStudioSave(operation, {
|
||||
signal: controller.signal,
|
||||
sleep: async (_delayMs, signal) => {
|
||||
controller.abort();
|
||||
if (signal?.aborted) throw new DOMException("Save aborted", "AbortError");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(operation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user