beta-real-debrid-downloader/tests/retry-manager.test.ts
Sucukdeluxe efa0909e11 feat: Download System v2 — complete rewrite of download pipeline
Replace monolithic download-manager.ts (9500 lines) with 7 focused modules:

- error-classifier.ts: 25+ typed DownloadErrorKind enum, classifier functions
  for network/HTTP/debrid/extraction errors — no more string matching
- retry-manager.ts: Declarative per-error-kind retry policies, exponential
  backoff, shelving after 15 failures, state export/import
- stream-writer.ts: HTTP stream → file with pre-resume validation, stall
  detection, NTFS-aligned buffered writing, Range-ignored detection
- pipeline.ts: Single download lifecycle (unrestrict → stream → verify),
  throws typed errors, caller decides retry strategy
- post-processor.ts: Extraction state machine with hard caps (3 attempts
  per archive, 5 rounds per package), no infinite loops
- scheduler.ts: Queue management with priority-based slot allocation,
  heartbeat stall detection, global watchdog, provider cooldowns
- download-manager.ts: Drop-in orchestrator (~1500 lines), same public API

Fixes:
1. Hanging downloads: heartbeat-based stall detection + global watchdog
2. Wrong error classification: typed enum at point of origin
3. Unreliable resume: file size vs tracker validation, Range-ignored detection
4. Extraction loops: bounded retries with state machine

215 new unit tests for error-classifier and retry-manager (all passing).
Build compiles cleanly. Same IPC interface — UI unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:14:17 +01:00

813 lines
29 KiB
TypeScript

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<string, RetryState> = {
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();
});
});