import { describe, it, expect, beforeEach } from "vitest"; import { DownloadError, DownloadErrorKind, } from "../src/main/download/error-classifier"; import { RetryManager, RETRY_POLICIES, RetryPolicy, RetryState, } from "../src/main/download/retry-manager"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** All values of DownloadErrorKind. */ const ALL_KINDS = Object.values(DownloadErrorKind) as DownloadErrorKind[]; /** Convenience: create a DownloadError for a given kind. */ function mkError(kind: DownloadErrorKind, msg = "test error"): DownloadError { return new DownloadError(kind, msg); } /** Feed N failures of the same kind and return the last decision. */ function failNTimes( mgr: RetryManager, itemId: string, kind: DownloadErrorKind, n: number, ) { let last; for (let i = 0; i < n; i++) { last = mgr.evaluate(itemId, mkError(kind)); } return last!; } // --------------------------------------------------------------------------- // 1) RETRY_POLICIES — completeness // --------------------------------------------------------------------------- describe("RETRY_POLICIES", () => { it("has a policy defined for every DownloadErrorKind value", () => { for (const kind of ALL_KINDS) { expect(RETRY_POLICIES[kind], `missing policy for ${kind}`).toBeDefined(); } }); it("every policy has valid shape", () => { for (const kind of ALL_KINDS) { const p = RETRY_POLICIES[kind]; expect(p.maxRetries).toBeGreaterThanOrEqual(0); expect(["fixed", "exponential"]).toContain(p.backoff); expect(p.baseDelayMs).toBeGreaterThanOrEqual(0); expect(p.maxDelayMs).toBeGreaterThanOrEqual(p.baseDelayMs); expect(typeof p.resetFile).toBe("boolean"); expect(typeof p.switchProvider).toBe("boolean"); expect(typeof p.refreshLink).toBe("boolean"); expect(p.providerCooldownMs).toBeGreaterThanOrEqual(0); } }); it("no unknown keys in RETRY_POLICIES beyond the enum values", () => { const policyKeys = Object.keys(RETRY_POLICIES); const enumValues = ALL_KINDS as string[]; for (const key of policyKeys) { expect(enumValues, `unexpected key "${key}" in RETRY_POLICIES`).toContain( key, ); } }); }); // --------------------------------------------------------------------------- // 2) RetryManager.evaluate() — basic decisions // --------------------------------------------------------------------------- describe("RetryManager.evaluate()", () => { let mgr: RetryManager; beforeEach(() => { mgr = new RetryManager(); }); it("returns shouldRetry=true on first retryable failure", () => { const d = mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); expect(d.shouldRetry).toBe(true); expect(d.delayMs).toBeGreaterThan(0); expect(d.reason).toContain("1/"); }); it("tracks failure counts per kind", () => { mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); const state = mgr.getState("a")!; expect(state.failuresByKind[DownloadErrorKind.Timeout]).toBe(2); expect(state.totalFailures).toBe(2); }); it("tracks multiple error kinds independently", () => { mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); mgr.evaluate("a", mkError(DownloadErrorKind.ServerError)); mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); const state = mgr.getState("a")!; expect(state.failuresByKind[DownloadErrorKind.Timeout]).toBe(2); expect(state.failuresByKind[DownloadErrorKind.ServerError]).toBe(1); expect(state.totalFailures).toBe(3); }); it("stores last error kind and message on state", () => { mgr.evaluate("x", mkError(DownloadErrorKind.ServerError, "500 oops")); const state = mgr.getState("x")!; expect(state.lastErrorKind).toBe(DownloadErrorKind.ServerError); expect(state.lastErrorMessage).toBe("500 oops"); }); it("keeps separate state per item", () => { mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); mgr.evaluate("b", mkError(DownloadErrorKind.ServerError)); expect(mgr.getState("a")!.totalFailures).toBe(1); expect(mgr.getState("b")!.totalFailures).toBe(1); expect(mgr.getState("a")!.lastErrorKind).toBe(DownloadErrorKind.Timeout); expect(mgr.getState("b")!.lastErrorKind).toBe(DownloadErrorKind.ServerError); }); it("respects userRetryLimit when set", () => { const limited = new RetryManager(2); // Timeout normally has maxRetries=10, but user limit is 2 const d1 = limited.evaluate("a", mkError(DownloadErrorKind.Timeout)); expect(d1.shouldRetry).toBe(true); const d2 = limited.evaluate("a", mkError(DownloadErrorKind.Timeout)); expect(d2.shouldRetry).toBe(true); // Third attempt exceeds limit (kindCount=3 > effectiveMax=2) const d3 = limited.evaluate("a", mkError(DownloadErrorKind.Timeout)); expect(d3.shouldRetry).toBe(false); }); it("setRetryLimit updates limit dynamically", () => { const m = new RetryManager(1); m.evaluate("a", mkError(DownloadErrorKind.Timeout)); // 1/1, ok const d2 = m.evaluate("a", mkError(DownloadErrorKind.Timeout)); // 2 > 1, fail expect(d2.shouldRetry).toBe(false); // Raise limit; new item should get more room m.setRetryLimit(5); const d3 = m.evaluate("b", mkError(DownloadErrorKind.Timeout)); expect(d3.shouldRetry).toBe(true); }); it("setRetryLimit clamps negative values to 0", () => { const m = new RetryManager(); m.setRetryLimit(-5); // 0 = unlimited, uses policy max const d = m.evaluate("a", mkError(DownloadErrorKind.Timeout)); expect(d.shouldRetry).toBe(true); }); }); // --------------------------------------------------------------------------- // 3) Exponential backoff — delays increase with attempts // --------------------------------------------------------------------------- describe("exponential backoff", () => { it("delay increases with attempt count for exponential policies", () => { const mgr = new RetryManager(); // Timeout uses exponential backoff with baseDelayMs=200, maxDelayMs=30000 const delays: number[] = []; for (let i = 0; i < 5; i++) { const d = mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); delays.push(d.delayMs); } // With jitter, exact values are nondeterministic, but the trend // should be non-decreasing (or at worst slightly noisy). // Check that the 5th delay >= 1st delay (accounting for the 1.5^n growth). expect(delays[4]).toBeGreaterThanOrEqual(delays[0]); }); it("delay is capped at maxDelayMs", () => { const mgr = new RetryManager(); // Use Timeout: maxDelayMs=30_000. After many retries delay should cap. for (let i = 0; i < 9; i++) { mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); } const d = mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); expect(d.delayMs).toBeLessThanOrEqual(30_000); }); it("fixed backoff returns the same delay every time", () => { const mgr = new RetryManager(); // NetworkReset is fixed at 300ms const d1 = mgr.evaluate("a", mkError(DownloadErrorKind.NetworkReset)); const d2 = mgr.evaluate("a", mkError(DownloadErrorKind.NetworkReset)); expect(d1.delayMs).toBe(300); expect(d2.delayMs).toBe(300); }); it("exponential delay is always >= 50% of the capped value", () => { // computeDelay: max(capped*0.5, capped - jitter) where jitter = capped*random*0.5 // so result is always >= capped * 0.5 const mgr = new RetryManager(); const policy = RETRY_POLICIES[DownloadErrorKind.Timeout]; for (let i = 0; i < 8; i++) { const d = mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); // On attempt i+1, base = 200 * 1.5^i, capped = min(base, 30000) const base = policy.baseDelayMs * Math.pow(1.5, i); const capped = Math.min(base, policy.maxDelayMs); expect(d.delayMs).toBeGreaterThanOrEqual(Math.floor(capped * 0.5)); expect(d.delayMs).toBeLessThanOrEqual(capped); } }); }); // --------------------------------------------------------------------------- // 4) Max retries — shouldRetry=false after exhausting retries // --------------------------------------------------------------------------- describe("max retries exhaustion", () => { it("shouldRetry becomes false after maxRetries+1 failures for a retryable kind", () => { const mgr = new RetryManager(); const kind = DownloadErrorKind.NetworkReset; // maxRetries=3 const policy = RETRY_POLICIES[kind]; for (let i = 0; i < policy.maxRetries; i++) { const d = mgr.evaluate("a", mkError(kind)); expect(d.shouldRetry, `attempt ${i + 1} should be retryable`).toBe(true); } // Next failure exceeds limit const final = mgr.evaluate("a", mkError(kind)); expect(final.shouldRetry).toBe(false); expect(final.delayMs).toBe(0); expect(final.actions).toEqual([]); expect(final.reason).toContain("erschöpft"); }); it("exhaustion message includes count and max", () => { const mgr = new RetryManager(); const kind = DownloadErrorKind.DnsFailure; // maxRetries=2 failNTimes(mgr, "a", kind, 2); // use up retries const d = mgr.evaluate("a", mkError(kind)); // 3rd fail expect(d.shouldRetry).toBe(false); expect(d.reason).toMatch(/3\/2/); }); it("each kind's retries are tracked independently", () => { const mgr = new RetryManager(); // Exhaust NetworkReset (3 retries) failNTimes(mgr, "a", DownloadErrorKind.NetworkReset, 3); const d1 = mgr.evaluate("a", mkError(DownloadErrorKind.NetworkReset)); expect(d1.shouldRetry).toBe(false); // Timeout should still be retryable (different kind) const d2 = mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); expect(d2.shouldRetry).toBe(true); }); }); // --------------------------------------------------------------------------- // 5) Permanent errors — no retry // --------------------------------------------------------------------------- describe("permanent errors", () => { const permanentKinds: DownloadErrorKind[] = [ DownloadErrorKind.LinkDead, DownloadErrorKind.DiskFull, DownloadErrorKind.PermissionDenied, DownloadErrorKind.WrongPassword, ]; for (const kind of permanentKinds) { it(`${kind} is never retried`, () => { const mgr = new RetryManager(); const d = mgr.evaluate("a", mkError(kind)); expect(d.shouldRetry).toBe(false); expect(d.delayMs).toBe(0); expect(d.actions).toEqual([]); }); } it("permanent errors return shouldRetry=false even on first attempt", () => { const mgr = new RetryManager(); for (const kind of permanentKinds) { const d = mgr.evaluate(kind, mkError(kind)); expect(d.shouldRetry, `${kind} should not retry`).toBe(false); } }); it("permanent kinds also have maxRetries=0 in their policies", () => { for (const kind of permanentKinds) { expect( RETRY_POLICIES[kind].maxRetries, `${kind} should have maxRetries=0`, ).toBe(0); } }); }); // --------------------------------------------------------------------------- // 6) Retry actions — correct actions per policy // --------------------------------------------------------------------------- describe("retry actions", () => { let mgr: RetryManager; beforeEach(() => { mgr = new RetryManager(); }); it("reset_file action for NetworkReset (resetFile=true)", () => { const d = mgr.evaluate("a", mkError(DownloadErrorKind.NetworkReset)); expect(d.actions).toContain("reset_file"); }); it("no switch_provider for NetworkReset (switchProvider=false)", () => { const d = mgr.evaluate("a", mkError(DownloadErrorKind.NetworkReset)); expect(d.actions).not.toContain("switch_provider"); }); it("switch_provider action for UnrestrictFailed (switchProvider=true)", () => { const d = mgr.evaluate("a", mkError(DownloadErrorKind.UnrestrictFailed)); expect(d.actions).toContain("switch_provider"); }); it("cooldown_provider action for UnrestrictFailed (providerCooldownMs > 0)", () => { const d = mgr.evaluate("a", mkError(DownloadErrorKind.UnrestrictFailed)); expect(d.actions).toContain("cooldown_provider"); }); it("refresh_link action for ConnectTimeout (refreshLink=true)", () => { const d = mgr.evaluate("a", mkError(DownloadErrorKind.ConnectTimeout)); expect(d.actions).toContain("refresh_link"); }); it("no actions for permanent errors", () => { const d = mgr.evaluate("a", mkError(DownloadErrorKind.LinkDead)); expect(d.actions).toEqual([]); }); it("ProviderBusy yields switch_provider + cooldown_provider", () => { const d = mgr.evaluate("a", mkError(DownloadErrorKind.ProviderBusy)); expect(d.actions).toContain("switch_provider"); expect(d.actions).toContain("cooldown_provider"); expect(d.actions).not.toContain("reset_file"); expect(d.actions).not.toContain("refresh_link"); }); it("FileCorrupt yields reset_file + refresh_link", () => { const d = mgr.evaluate("a", mkError(DownloadErrorKind.FileCorrupt)); expect(d.actions).toContain("reset_file"); expect(d.actions).toContain("refresh_link"); expect(d.actions).not.toContain("switch_provider"); }); it("FileLocked has no special actions", () => { const d = mgr.evaluate("a", mkError(DownloadErrorKind.FileLocked)); expect(d.actions).toEqual([]); }); it("Timeout has no special actions", () => { const d = mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); expect(d.actions).toEqual([]); }); it("actions list matches policy flags for every retryable kind", () => { for (const kind of ALL_KINDS) { const policy = RETRY_POLICIES[kind]; if (policy.maxRetries === 0) continue; // permanent or zero-retry const d = mgr.evaluate(`action-check-${kind}`, mkError(kind)); if (!d.shouldRetry) continue; if (policy.resetFile) { expect(d.actions, `${kind}: missing reset_file`).toContain("reset_file"); } else { expect(d.actions, `${kind}: unexpected reset_file`).not.toContain("reset_file"); } if (policy.switchProvider) { expect(d.actions, `${kind}: missing switch_provider`).toContain("switch_provider"); } else { expect(d.actions, `${kind}: unexpected switch_provider`).not.toContain("switch_provider"); } if (policy.refreshLink) { expect(d.actions, `${kind}: missing refresh_link`).toContain("refresh_link"); } else { expect(d.actions, `${kind}: unexpected refresh_link`).not.toContain("refresh_link"); } if (policy.providerCooldownMs > 0) { expect(d.actions, `${kind}: missing cooldown_provider`).toContain("cooldown_provider"); } else { expect(d.actions, `${kind}: unexpected cooldown_provider`).not.toContain("cooldown_provider"); } } }); }); // --------------------------------------------------------------------------- // 7) Shelving — triggers after SHELVE_THRESHOLD (15) total failures // --------------------------------------------------------------------------- describe("shelving", () => { const SHELVE_THRESHOLD = 15; const SHELVE_DELAY_MS = 90_000; it("triggers shelving at exactly 15 total failures", () => { const mgr = new RetryManager(); // Use a kind with high maxRetries so we don't exhaust it first const kind = DownloadErrorKind.Timeout; // maxRetries=10 // Mix in some ServerError too to stay under per-kind limits for (let i = 0; i < 10; i++) { mgr.evaluate("a", mkError(kind)); } for (let i = 0; i < 4; i++) { mgr.evaluate("a", mkError(DownloadErrorKind.ServerError)); } // Next one is the 15th failure -> shelve const d = mgr.evaluate("a", mkError(DownloadErrorKind.ServerError)); expect(d.shouldRetry).toBe(true); expect(d.delayMs).toBe(SHELVE_DELAY_MS); expect(d.actions).toContain("shelve"); expect(d.actions).toContain("switch_provider"); expect(d.actions).toContain("refresh_link"); }); it("shelving halves all kind counters", () => { const mgr = new RetryManager(); for (let i = 0; i < 10; i++) { mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); } for (let i = 0; i < 4; i++) { mgr.evaluate("a", mkError(DownloadErrorKind.ServerError)); } // 15th failure -> shelve mgr.evaluate("a", mkError(DownloadErrorKind.ServerError)); const state = mgr.getState("a")!; // After halving: Timeout 10->5, ServerError 5->2, total=7 expect(state.failuresByKind[DownloadErrorKind.Timeout]).toBe(5); expect(state.failuresByKind[DownloadErrorKind.ServerError]).toBe(2); expect(state.totalFailures).toBe(7); expect(state.shelveCount).toBe(1); }); it("shelving increments shelveCount", () => { const mgr = new RetryManager(); // Trigger shelve twice // First round: 15 failures -> shelve (halves to ~7) for (let i = 0; i < 15; i++) { mgr.evaluate("a", mkError(DownloadErrorKind.Unknown)); } const state1 = mgr.getState("a")!; expect(state1.shelveCount).toBe(1); // After halving, totalFailures is ~7. Need 8 more to reach 15 again. const remaining = SHELVE_THRESHOLD - state1.totalFailures; for (let i = 0; i < remaining; i++) { mgr.evaluate("a", mkError(DownloadErrorKind.Unknown)); } const state2 = mgr.getState("a")!; expect(state2.shelveCount).toBe(2); }); it("shelve decision always has shouldRetry=true", () => { const mgr = new RetryManager(); for (let i = 0; i < 15; i++) { mgr.evaluate("a", mkError(DownloadErrorKind.Unknown)); } // The 15th call itself triggers shelve // Let's re-check: the state now has halved counters. // One more batch to trigger shelve again const state = mgr.getState("a")!; const needed = SHELVE_THRESHOLD - state.totalFailures; for (let i = 0; i < needed - 1; i++) { mgr.evaluate("a", mkError(DownloadErrorKind.Unknown)); } const d = mgr.evaluate("a", mkError(DownloadErrorKind.Unknown)); expect(d.shouldRetry).toBe(true); expect(d.delayMs).toBe(SHELVE_DELAY_MS); }); it("shelve is checked before per-kind exhaustion", () => { const mgr = new RetryManager(); // NetworkReset has maxRetries=3. If we mix kinds to reach 15 total // without exhausting any single kind, shelve takes priority. // Use 5 kinds, 3 each = 15 const kinds = [ DownloadErrorKind.Timeout, DownloadErrorKind.ServerError, DownloadErrorKind.RateLimited, DownloadErrorKind.Unknown, DownloadErrorKind.WriteDrainTimeout, ]; for (let i = 0; i < 14; i++) { mgr.evaluate("a", mkError(kinds[i % kinds.length])); } // 15th failure -> shelve (not per-kind exhaustion) const d = mgr.evaluate("a", mkError(kinds[4])); expect(d.actions).toContain("shelve"); expect(d.shouldRetry).toBe(true); }); }); // --------------------------------------------------------------------------- // 8) resetItem() — clears retry state // --------------------------------------------------------------------------- describe("resetItem()", () => { it("removes all state for the given item", () => { const mgr = new RetryManager(); mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); expect(mgr.getState("a")).toBeDefined(); mgr.resetItem("a"); expect(mgr.getState("a")).toBeUndefined(); }); it("after reset, the item starts fresh", () => { const mgr = new RetryManager(); // Accumulate some failures failNTimes(mgr, "a", DownloadErrorKind.NetworkReset, 3); mgr.resetItem("a"); // First failure after reset should be attempt 1 const d = mgr.evaluate("a", mkError(DownloadErrorKind.NetworkReset)); expect(d.shouldRetry).toBe(true); expect(d.reason).toContain("1/"); }); it("resetting one item does not affect other items", () => { const mgr = new RetryManager(); mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); mgr.evaluate("b", mkError(DownloadErrorKind.Timeout)); mgr.resetItem("a"); expect(mgr.getState("a")).toBeUndefined(); expect(mgr.getState("b")).toBeDefined(); expect(mgr.getState("b")!.totalFailures).toBe(1); }); it("resetting a non-existent item is a no-op", () => { const mgr = new RetryManager(); // Should not throw expect(() => mgr.resetItem("nonexistent")).not.toThrow(); }); }); // --------------------------------------------------------------------------- // 9) softReset() — halves counters // --------------------------------------------------------------------------- describe("softReset()", () => { it("halves failure counts for all items", () => { const mgr = new RetryManager(); failNTimes(mgr, "a", DownloadErrorKind.Timeout, 8); failNTimes(mgr, "b", DownloadErrorKind.ServerError, 6); mgr.softReset(); const stateA = mgr.getState("a")!; expect(stateA.failuresByKind[DownloadErrorKind.Timeout]).toBe(4); expect(stateA.totalFailures).toBe(4); const stateB = mgr.getState("b")!; expect(stateB.failuresByKind[DownloadErrorKind.ServerError]).toBe(3); expect(stateB.totalFailures).toBe(3); }); it("uses floor division (odd counts lose the remainder)", () => { const mgr = new RetryManager(); failNTimes(mgr, "a", DownloadErrorKind.Timeout, 5); mgr.softReset(); const state = mgr.getState("a")!; expect(state.failuresByKind[DownloadErrorKind.Timeout]).toBe(2); // floor(5/2) expect(state.totalFailures).toBe(2); }); it("totalFailures is recalculated from individual kind counts", () => { const mgr = new RetryManager(); failNTimes(mgr, "a", DownloadErrorKind.Timeout, 7); failNTimes(mgr, "a", DownloadErrorKind.ServerError, 3); // total = 10 mgr.softReset(); const state = mgr.getState("a")!; // Timeout: floor(7/2) = 3, ServerError: floor(3/2) = 1 => total = 4 expect(state.failuresByKind[DownloadErrorKind.Timeout]).toBe(3); expect(state.failuresByKind[DownloadErrorKind.ServerError]).toBe(1); expect(state.totalFailures).toBe(4); }); it("double softReset keeps halving", () => { const mgr = new RetryManager(); failNTimes(mgr, "a", DownloadErrorKind.Timeout, 8); mgr.softReset(); // 8 -> 4 mgr.softReset(); // 4 -> 2 const state = mgr.getState("a")!; expect(state.failuresByKind[DownloadErrorKind.Timeout]).toBe(2); expect(state.totalFailures).toBe(2); }); it("softReset on zero-failure items is a no-op", () => { const mgr = new RetryManager(); mgr.evaluate("a", mkError(DownloadErrorKind.LinkDead)); // permanent, but state exists const stateBefore = { ...mgr.getState("a")! }; // totalFailures is 1, so softReset will halve it mgr.softReset(); const stateAfter = mgr.getState("a")!; // floor(1/2) = 0 expect(stateAfter.totalFailures).toBe(0); }); it("softReset does not remove items from the map", () => { const mgr = new RetryManager(); failNTimes(mgr, "a", DownloadErrorKind.Timeout, 2); mgr.softReset(); expect(mgr.getState("a")).toBeDefined(); }); it("softReset allows previously exhausted kinds to retry", () => { const mgr = new RetryManager(); // NetworkReset maxRetries=3. Exhaust it. failNTimes(mgr, "a", DownloadErrorKind.NetworkReset, 3); const exhausted = mgr.evaluate("a", mkError(DownloadErrorKind.NetworkReset)); expect(exhausted.shouldRetry).toBe(false); // softReset: kindCount 4 -> 2, total 4 -> 2 mgr.softReset(); // Now kindCount=2, effectiveMax=3, so 2 <= 3 → retry const recovered = mgr.evaluate("a", mkError(DownloadErrorKind.NetworkReset)); expect(recovered.shouldRetry).toBe(true); }); }); // --------------------------------------------------------------------------- // 10) State export/import — roundtrip // --------------------------------------------------------------------------- describe("exportStates() and importStates()", () => { it("roundtrips state faithfully", () => { const mgr = new RetryManager(); mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); mgr.evaluate("a", mkError(DownloadErrorKind.ServerError)); mgr.evaluate("b", mkError(DownloadErrorKind.NetworkReset)); const exported = mgr.exportStates(); const mgr2 = new RetryManager(); mgr2.importStates(exported); expect(mgr2.getState("a")!.totalFailures).toBe(2); expect(mgr2.getState("a")!.failuresByKind[DownloadErrorKind.Timeout]).toBe(1); expect(mgr2.getState("a")!.failuresByKind[DownloadErrorKind.ServerError]).toBe(1); expect(mgr2.getState("b")!.totalFailures).toBe(1); expect(mgr2.getState("b")!.failuresByKind[DownloadErrorKind.NetworkReset]).toBe(1); }); it("exported states are deep copies (no shared references)", () => { const mgr = new RetryManager(); mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); const exported = mgr.exportStates(); // Mutate the export exported["a"].totalFailures = 999; exported["a"].failuresByKind[DownloadErrorKind.Timeout] = 999; // Original should be unaffected const state = mgr.getState("a")!; expect(state.totalFailures).toBe(1); expect(state.failuresByKind[DownloadErrorKind.Timeout]).toBe(1); }); it("importStates clears previous state", () => { const mgr = new RetryManager(); mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); mgr.evaluate("b", mkError(DownloadErrorKind.ServerError)); // Import only "c" mgr.importStates({ c: { failuresByKind: { [DownloadErrorKind.DnsFailure]: 1 }, totalFailures: 1, shelveCount: 0, }, }); expect(mgr.getState("a")).toBeUndefined(); expect(mgr.getState("b")).toBeUndefined(); expect(mgr.getState("c")).toBeDefined(); expect(mgr.getState("c")!.totalFailures).toBe(1); }); it("importStates deep-copies input (no shared references)", () => { const mgr = new RetryManager(); const input: Record = { x: { failuresByKind: { [DownloadErrorKind.Timeout]: 3 }, totalFailures: 3, shelveCount: 0, }, }; mgr.importStates(input); // Mutate the input after import input.x.totalFailures = 999; input.x.failuresByKind[DownloadErrorKind.Timeout] = 999; const state = mgr.getState("x")!; expect(state.totalFailures).toBe(3); expect(state.failuresByKind[DownloadErrorKind.Timeout]).toBe(3); }); it("empty export for fresh manager", () => { const mgr = new RetryManager(); const exported = mgr.exportStates(); expect(exported).toEqual({}); }); it("import empty object clears all state", () => { const mgr = new RetryManager(); mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); mgr.importStates({}); expect(mgr.getState("a")).toBeUndefined(); expect(mgr.exportStates()).toEqual({}); }); it("shelveCount survives export/import roundtrip", () => { const mgr = new RetryManager(); // Trigger shelve for (let i = 0; i < 15; i++) { mgr.evaluate("a", mkError(DownloadErrorKind.Unknown)); } const originalShelve = mgr.getState("a")!.shelveCount; expect(originalShelve).toBeGreaterThan(0); const exported = mgr.exportStates(); const mgr2 = new RetryManager(); mgr2.importStates(exported); expect(mgr2.getState("a")!.shelveCount).toBe(originalShelve); }); it("continued evaluation works after import", () => { const mgr = new RetryManager(); failNTimes(mgr, "a", DownloadErrorKind.NetworkReset, 2); const exported = mgr.exportStates(); const mgr2 = new RetryManager(); mgr2.importStates(exported); // 3rd attempt (maxRetries=3) should still be retryable const d = mgr2.evaluate("a", mkError(DownloadErrorKind.NetworkReset)); expect(d.shouldRetry).toBe(true); // 4th attempt exceeds limit const d2 = mgr2.evaluate("a", mkError(DownloadErrorKind.NetworkReset)); expect(d2.shouldRetry).toBe(false); }); }); // --------------------------------------------------------------------------- // restoreState() and removeItem() // --------------------------------------------------------------------------- describe("restoreState()", () => { it("restores a single item's state", () => { const mgr = new RetryManager(); mgr.restoreState("x", { failuresByKind: { [DownloadErrorKind.Timeout]: 5 }, totalFailures: 5, shelveCount: 1, lastErrorKind: DownloadErrorKind.Timeout, lastErrorMessage: "stalled", }); const state = mgr.getState("x")!; expect(state.totalFailures).toBe(5); expect(state.shelveCount).toBe(1); expect(state.lastErrorKind).toBe(DownloadErrorKind.Timeout); }); it("restoreState does not affect other items", () => { const mgr = new RetryManager(); mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); mgr.restoreState("b", { failuresByKind: {}, totalFailures: 0, shelveCount: 0, }); expect(mgr.getState("a")!.totalFailures).toBe(1); expect(mgr.getState("b")!.totalFailures).toBe(0); }); }); describe("removeItem()", () => { it("removes state for a specific item", () => { const mgr = new RetryManager(); mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); mgr.evaluate("b", mkError(DownloadErrorKind.Timeout)); mgr.removeItem("a"); expect(mgr.getState("a")).toBeUndefined(); expect(mgr.getState("b")).toBeDefined(); }); it("removing non-existent item is a no-op", () => { const mgr = new RetryManager(); expect(() => mgr.removeItem("nope")).not.toThrow(); }); });