feat(import): durable batch checkpoint and transient auto-retry
Mirror interactive batch runs into the import-job store so interrupted imports (restart, time-out, disconnect) can be resumed from Import History. Items are checkpointed as they settle (coalesced, serialized saves) and the mirror starts 'running' so the boot-time worker marks it 'interrupted' instead of double-importing; done items are never re-imported. Add bounded backoff retry for transient download/connection failures before marking an item failed, and point the client's time-out/network toasts at Import History.
This commit is contained in:
1 parent
af1a06b0a3
commit
ffcd4232d6
4 files changed
+241
-4
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -933,10 +933,12 @@ export function StudioClient({
|
|||||||
if ((err as Error).name === "AbortError") {
|
if ((err as Error).name === "AbortError") {
|
||||||
toast.info("Import cancelled");
|
toast.info("Import cancelled");
|
||||||
} else if ((err as Error).name === "TimeoutError") {
|
} else if ((err as Error).name === "TimeoutError") {
|
||||||
toast.error("Import timed out. Retry or reduce concurrency.");
|
toast.error(
|
||||||
|
"Import timed out. Progress was saved — open Import History to resume failed items.",
|
||||||
|
);
|
||||||
} else if (err instanceof TypeError && err.message?.includes("fetch")) {
|
} else if (err instanceof TypeError && err.message?.includes("fetch")) {
|
||||||
toast.error(
|
toast.error(
|
||||||
"Network error during upload. Check your connection and retry.",
|
"Network error during upload. Progress was saved — open Import History to resume failed items.",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
isTransientImportError,
|
||||||
|
withTransientImportRetry,
|
||||||
|
} from "./transient-retry";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isTransientImportError", () => {
|
||||||
|
it("flags connection and timeout classes", () => {
|
||||||
|
for (const message of [
|
||||||
|
"fetch failed",
|
||||||
|
"network error during download",
|
||||||
|
"service unavailable",
|
||||||
|
"HTTP 503",
|
||||||
|
"HTTP 500",
|
||||||
|
"Empty or truncated response",
|
||||||
|
"connection timed out",
|
||||||
|
"ECONNRESET",
|
||||||
|
]) {
|
||||||
|
expect(isTransientImportError(message)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not flag data errors or aborts", () => {
|
||||||
|
for (const message of [
|
||||||
|
undefined,
|
||||||
|
"Response is not a SWF file",
|
||||||
|
"Invalid Nitro bundle",
|
||||||
|
"This operation was aborted",
|
||||||
|
"classname missing in assets",
|
||||||
|
"HTTP 404",
|
||||||
|
]) {
|
||||||
|
expect(isTransientImportError(message)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("withTransientImportRetry", () => {
|
||||||
|
it("returns success on the first attempt", async () => {
|
||||||
|
const attempt = vi.fn(async () => ({ ok: true as const, itemId: 1 }));
|
||||||
|
await expect(withTransientImportRetry(attempt)).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
itemId: 1,
|
||||||
|
});
|
||||||
|
expect(attempt).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries transient failures until success", async () => {
|
||||||
|
const attempt = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ ok: false as const, error: "fetch failed" })
|
||||||
|
.mockResolvedValueOnce({ ok: false as const, error: "HTTP 503" })
|
||||||
|
.mockResolvedValueOnce({ ok: true as const, itemId: 7 });
|
||||||
|
const pending = withTransientImportRetry(attempt, 2);
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
await expect(pending).resolves.toEqual({ ok: true, itemId: 7 });
|
||||||
|
expect(attempt).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the last error after exhausting retries", async () => {
|
||||||
|
const attempt = vi.fn(async () => ({
|
||||||
|
ok: false as const,
|
||||||
|
error: "fetch failed",
|
||||||
|
}));
|
||||||
|
const pending = withTransientImportRetry(attempt, 2);
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
await expect(pending).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "fetch failed",
|
||||||
|
});
|
||||||
|
expect(attempt).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry non-transient failures", async () => {
|
||||||
|
const attempt = vi.fn(async () => ({
|
||||||
|
ok: false as const,
|
||||||
|
error: "Invalid Nitro bundle",
|
||||||
|
}));
|
||||||
|
await expect(withTransientImportRetry(attempt)).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Invalid Nitro bundle",
|
||||||
|
});
|
||||||
|
expect(attempt).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
const TRANSIENT_ERROR_PATTERN =
|
||||||
|
/fetch failed|network|socket|timeout|timed out|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|ECONNREFUSED|EHOSTUNREACH|HTTP 5\d\d|Empty or truncated response|service unavailable|temporarily unavailable/i;
|
||||||
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
/** True for download/connection hiccups worth a retry; false for data errors. */
|
||||||
|
export function isTransientImportError(message: string | undefined): boolean {
|
||||||
|
if (!message) return false;
|
||||||
|
return TRANSIENT_ERROR_PATTERN.test(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs `attempt` again (with backoff) when it returns a transient failure.
|
||||||
|
* Non-transient failures and hard throws are returned/raised immediately.
|
||||||
|
*/
|
||||||
|
export async function withTransientImportRetry<
|
||||||
|
T extends { ok: boolean; error?: string },
|
||||||
|
>(attempt: () => Promise<T>, retries = 2): Promise<T> {
|
||||||
|
for (let n = 0; ; n++) {
|
||||||
|
const result = await attempt();
|
||||||
|
if (result.ok || !isTransientImportError(result.error) || n >= retries)
|
||||||
|
return result;
|
||||||
|
const delay = Math.min(400 * 2 ** n, 2000);
|
||||||
|
await sleep(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user