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>
This commit is contained in:
Sucukdeluxe
2026-03-08 18:14:17 +01:00
co-authored by Claude Opus 4.6
parent 63b412a43f
commit efa0909e11
14 changed files with 6970 additions and 2 deletions
+705
View File
@@ -0,0 +1,705 @@
import { describe, expect, it } from "vitest";
import {
DownloadError,
DownloadErrorKind,
classifyFetchError,
classifyHttpStatus,
classifyUnrestrictError,
classifyExtractionError,
classifyRangeIgnored,
ensureDownloadError,
errorKindLabel,
isPermanentKind,
} from "../src/main/download/error-classifier";
// ===========================================================================
// DownloadError construction and properties
// ===========================================================================
describe("DownloadError", () => {
it("stores kind, message, and defaults retryable/permanent from isPermanentKind", () => {
const err = new DownloadError(DownloadErrorKind.NetworkReset, "socket hang up");
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("DownloadError");
expect(err.kind).toBe(DownloadErrorKind.NetworkReset);
expect(err.message).toBe("socket hang up");
expect(err.retryable).toBe(true);
expect(err.permanent).toBe(false);
});
it("marks permanent kinds as non-retryable by default", () => {
const err = new DownloadError(DownloadErrorKind.LinkDead, "file deleted");
expect(err.retryable).toBe(false);
expect(err.permanent).toBe(true);
});
it("stores httpStatus when provided", () => {
const err = new DownloadError(DownloadErrorKind.ServerError, "HTTP 500", {
httpStatus: 500,
});
expect(err.httpStatus).toBe(500);
});
it("stores originalError when provided", () => {
const orig = new Error("root cause");
const err = new DownloadError(DownloadErrorKind.Unknown, "wrapped", {
originalError: orig,
});
expect(err.originalError).toBe(orig);
});
it("stores arbitrary context", () => {
const err = new DownloadError(DownloadErrorKind.RangeNotSatisfied, "range", {
context: { existingBytes: 1024, expectedTotal: 2048 },
});
expect(err.context).toEqual({ existingBytes: 1024, expectedTotal: 2048 });
});
it("allows overriding retryable and permanent via opts", () => {
// Override a normally-permanent kind to be retryable
const err = new DownloadError(DownloadErrorKind.DiskFull, "disk full", {
retryable: true,
permanent: false,
});
expect(err.retryable).toBe(true);
expect(err.permanent).toBe(false);
});
it("httpStatus is undefined when not provided", () => {
const err = new DownloadError(DownloadErrorKind.Unknown, "x");
expect(err.httpStatus).toBeUndefined();
});
it("toLogString produces a compact representation", () => {
const err = new DownloadError(DownloadErrorKind.ServerError, "Internal Server Error", {
httpStatus: 500,
});
const log = err.toLogString();
expect(log).toContain("[server_error]");
expect(log).toContain("Internal Server Error");
expect(log).toContain("(HTTP 500)");
});
it("toLogString omits HTTP status when not set", () => {
const err = new DownloadError(DownloadErrorKind.Timeout, "stalled");
const log = err.toLogString();
expect(log).toBe("[timeout] stalled");
expect(log).not.toContain("HTTP");
});
});
// ===========================================================================
// classifyFetchError
// ===========================================================================
describe("classifyFetchError", () => {
// ---- Network Reset ----
it.each([
"socket hang up",
"ECONNRESET",
"ECONNREFUSED",
"EPIPE broken pipe",
"network error on fetch",
"socket closed unexpectedly",
"connection reset by peer",
"fetch failed",
])("classifies '%s' as NetworkReset", (msg) => {
const err = classifyFetchError(new Error(msg));
expect(err.kind).toBe(DownloadErrorKind.NetworkReset);
expect(err.retryable).toBe(true);
});
// ---- Connection Timeout ----
it.each([
"ETIMEDOUT",
"connect_timeout reached",
"Connection timed out after 30s",
])("classifies '%s' as ConnectTimeout", (msg) => {
const err = classifyFetchError(new Error(msg));
expect(err.kind).toBe(DownloadErrorKind.ConnectTimeout);
expect(err.retryable).toBe(true);
});
// ---- DNS Failure ----
it.each([
"getaddrinfo ENOTFOUND example.com",
"ENOTFOUND",
"DNS lookup failed",
])("classifies '%s' as DnsFailure", (msg) => {
const err = classifyFetchError(new Error(msg));
expect(err.kind).toBe(DownloadErrorKind.DnsFailure);
expect(err.retryable).toBe(true);
});
// ---- Stall / Read Timeout ----
it.each([
"stall_timeout after 60s",
"read timeout waiting for data",
])("classifies '%s' as Timeout", (msg) => {
const err = classifyFetchError(new Error(msg));
expect(err.kind).toBe(DownloadErrorKind.Timeout);
expect(err.retryable).toBe(true);
});
// ---- Write Drain Timeout ----
it("classifies write_drain_timeout as WriteDrainTimeout", () => {
const err = classifyFetchError(new Error("write_drain_timeout: disk slow"));
expect(err.kind).toBe(DownloadErrorKind.WriteDrainTimeout);
expect(err.retryable).toBe(true);
});
// ---- Disk Full ----
it.each([
"ENOSPC: no space left on device",
"no space left on device",
])("classifies '%s' as DiskFull (permanent)", (msg) => {
const err = classifyFetchError(new Error(msg));
expect(err.kind).toBe(DownloadErrorKind.DiskFull);
expect(err.permanent).toBe(true);
expect(err.retryable).toBe(false);
});
// ---- Permission Denied ----
it.each([
"EACCES: permission denied '/tmp/f'",
"EPERM: operation not permitted",
"Permission denied writing to output",
])("classifies '%s' as PermissionDenied (permanent)", (msg) => {
const err = classifyFetchError(new Error(msg));
expect(err.kind).toBe(DownloadErrorKind.PermissionDenied);
expect(err.permanent).toBe(true);
});
// ---- File Locked ----
it.each([
"EBUSY: resource busy or locked",
"file is locked by another process",
"being used by another process",
])("classifies '%s' as FileLocked", (msg) => {
const err = classifyFetchError(new Error(msg));
expect(err.kind).toBe(DownloadErrorKind.FileLocked);
expect(err.retryable).toBe(true);
});
// ---- Resume Underflow ----
it("classifies resume_download_underflow as ResumeUnderflow", () => {
const err = classifyFetchError(new Error("resume_download_underflow:512/1024"));
expect(err.kind).toBe(DownloadErrorKind.ResumeUnderflow);
});
// ---- Range Ignored ----
it("classifies range_ignored_on_resume as RangeIgnored", () => {
const err = classifyFetchError(new Error("range_ignored_on_resume:512/2048"));
expect(err.kind).toBe(DownloadErrorKind.RangeIgnored);
});
// ---- Unknown ----
it("classifies an unrecognised message as Unknown", () => {
const err = classifyFetchError(new Error("something completely new"));
expect(err.kind).toBe(DownloadErrorKind.Unknown);
expect(err.retryable).toBe(true);
});
// ---- Abort handling ----
it("re-throws abort errors instead of classifying", () => {
expect(() => classifyFetchError(new Error("Aborted: user cancelled"))).toThrow();
});
it("re-throws abort errors for a plain 'abort' message", () => {
expect(() => classifyFetchError(new Error("abort"))).toThrow();
});
// ---- Non-Error inputs ----
it("handles a plain string as input", () => {
const err = classifyFetchError("ECONNRESET");
expect(err.kind).toBe(DownloadErrorKind.NetworkReset);
});
it("handles null/undefined gracefully", () => {
const err = classifyFetchError(null);
expect(err.kind).toBe(DownloadErrorKind.Unknown);
});
it("handles undefined gracefully", () => {
const err = classifyFetchError(undefined);
expect(err.kind).toBe(DownloadErrorKind.Unknown);
});
it("preserves originalError reference", () => {
const orig = new Error("ECONNRESET");
const err = classifyFetchError(orig);
expect(err.originalError).toBe(orig);
});
});
// ===========================================================================
// classifyHttpStatus
// ===========================================================================
describe("classifyHttpStatus", () => {
it("classifies 416 as RangeNotSatisfied", () => {
const err = classifyHttpStatus({ status: 416 });
expect(err.kind).toBe(DownloadErrorKind.RangeNotSatisfied);
expect(err.httpStatus).toBe(416);
});
it("stores existingBytes in context for 416", () => {
const err = classifyHttpStatus({ status: 416, existingBytes: 4096 });
expect(err.context).toEqual({ existingBytes: 4096 });
});
it("classifies 429 as RateLimited", () => {
const err = classifyHttpStatus({ status: 429, statusText: "Too Many Requests" });
expect(err.kind).toBe(DownloadErrorKind.RateLimited);
expect(err.httpStatus).toBe(429);
});
it("classifies 403 as Forbidden", () => {
const err = classifyHttpStatus({ status: 403 });
expect(err.kind).toBe(DownloadErrorKind.Forbidden);
expect(err.httpStatus).toBe(403);
});
it("classifies 404 as NotFound", () => {
const err = classifyHttpStatus({ status: 404, statusText: "Not Found" });
expect(err.kind).toBe(DownloadErrorKind.NotFound);
expect(err.httpStatus).toBe(404);
});
it("classifies 500 as ServerError", () => {
const err = classifyHttpStatus({ status: 500 });
expect(err.kind).toBe(DownloadErrorKind.ServerError);
expect(err.httpStatus).toBe(500);
});
it("classifies 502 as ServerError", () => {
const err = classifyHttpStatus({ status: 502, statusText: "Bad Gateway" });
expect(err.kind).toBe(DownloadErrorKind.ServerError);
expect(err.httpStatus).toBe(502);
});
it("classifies 503 as ServerError", () => {
const err = classifyHttpStatus({ status: 503, statusText: "Service Unavailable" });
expect(err.kind).toBe(DownloadErrorKind.ServerError);
expect(err.httpStatus).toBe(503);
});
it("classifies 401 as Unknown (no special branch)", () => {
const err = classifyHttpStatus({ status: 401 });
expect(err.kind).toBe(DownloadErrorKind.Unknown);
expect(err.httpStatus).toBe(401);
});
it("includes responseText in the message when provided", () => {
const err = classifyHttpStatus({ status: 500, responseText: "Internal Server Error" });
expect(err.message).toContain("500");
expect(err.message).toContain("Internal Server Error");
});
it("uses statusText as fallback when responseText is absent", () => {
const err = classifyHttpStatus({ status: 500, statusText: "Server Error" });
expect(err.message).toContain("Server Error");
});
it("produces message without body when neither responseText nor statusText is given", () => {
const err = classifyHttpStatus({ status: 500 });
expect(err.message).toBe("HTTP 500");
});
it("all server errors (5xx) are retryable", () => {
for (const code of [500, 502, 503, 504]) {
const err = classifyHttpStatus({ status: code });
expect(err.retryable).toBe(true);
}
});
});
// ===========================================================================
// classifyRangeIgnored
// ===========================================================================
describe("classifyRangeIgnored", () => {
it("returns RangeIgnored kind", () => {
const err = classifyRangeIgnored(1024, 4096);
expect(err.kind).toBe(DownloadErrorKind.RangeIgnored);
});
it("includes existingBytes and contentLength in the message", () => {
const err = classifyRangeIgnored(512, 2048);
expect(err.message).toContain("512");
expect(err.message).toContain("2048");
});
it("stores existingBytes and contentLength in context", () => {
const err = classifyRangeIgnored(1024, 8192);
expect(err.context).toEqual({ existingBytes: 1024, contentLength: 8192 });
});
it("is retryable by default", () => {
const err = classifyRangeIgnored(0, 100);
expect(err.retryable).toBe(true);
expect(err.permanent).toBe(false);
});
});
// ===========================================================================
// classifyUnrestrictError
// ===========================================================================
describe("classifyUnrestrictError", () => {
// ---- LinkDead (permanent) ----
it.each([
"File not found",
"file unavailable",
"Link is dead",
"File has been removed",
"file has been deleted",
"file is no longer available",
"file was removed from server",
"file was deleted by owner",
"permanent ungültig",
])("classifies '%s' as LinkDead (permanent)", (msg) => {
const err = classifyUnrestrictError(new Error(msg));
expect(err.kind).toBe(DownloadErrorKind.LinkDead);
expect(err.permanent).toBe(true);
expect(err.retryable).toBe(false);
});
// ---- ProviderBusy ----
it.each([
"too many active downloads",
"too many concurrent sessions",
"too many downloads at once",
"active download limit",
"concurrent limit exceeded",
"slot limit reached for this host",
"limit reached try later",
"zu viele aktive Downloads",
"zu viele gleichzeitige Transfers",
"zu viele Downloads",
])("classifies '%s' as ProviderBusy", (msg) => {
const err = classifyUnrestrictError(new Error(msg));
expect(err.kind).toBe(DownloadErrorKind.ProviderBusy);
expect(err.retryable).toBe(true);
});
// ---- HosterUnavailable ----
it("classifies 'hosternotavailable' as HosterUnavailable", () => {
const err = classifyUnrestrictError(new Error("hosternotavailable"));
expect(err.kind).toBe(DownloadErrorKind.HosterUnavailable);
expect(err.retryable).toBe(true);
});
// ---- QuotaExceeded ----
it.each([
"quota exceeded for today",
"bandwidth limit exceeded",
])("classifies '%s' as QuotaExceeded", (msg) => {
const err = classifyUnrestrictError(new Error(msg));
expect(err.kind).toBe(DownloadErrorKind.QuotaExceeded);
expect(err.retryable).toBe(true);
});
// ---- ProviderDown ----
it.each([
"server error occurred",
"internal server error",
"temporarily unavailable",
"temporary unavailable please wait",
"temporarily disabled",
"try again later",
"service unavailable",
"host is down",
"maintenance in progress",
"bad gateway",
"gateway timeout",
"cloudflare challenge detected",
"worker error at edge",
])("classifies '%s' as ProviderDown", (msg) => {
const err = classifyUnrestrictError(new Error(msg));
expect(err.kind).toBe(DownloadErrorKind.ProviderDown);
expect(err.retryable).toBe(true);
});
// ---- UnrestrictFailed ----
it.each([
"unrestrict call failed",
"mega-web provider error",
"mega-debrid session lost",
"bestdebrid API error",
"alldebrid unrestrict failed",
"kein debrid-provider verfügbar",
"session-cookie expired",
"session cookie invalid",
"session blockiert",
"session expired please re-login",
"invalid session token",
"login ungültig",
"login liefert HTTP 401",
"login required for this host",
"login failed with credentials",
])("classifies '%s' as UnrestrictFailed", (msg) => {
const err = classifyUnrestrictError(new Error(msg));
expect(err.kind).toBe(DownloadErrorKind.UnrestrictFailed);
expect(err.retryable).toBe(true);
});
// ---- Unknown ----
it("classifies unrecognised debrid error as Unknown", () => {
const err = classifyUnrestrictError(new Error("completely unknown debrid error"));
expect(err.kind).toBe(DownloadErrorKind.Unknown);
});
// ---- Non-Error inputs ----
it("handles a plain string as input", () => {
const err = classifyUnrestrictError("hosternotavailable");
expect(err.kind).toBe(DownloadErrorKind.HosterUnavailable);
});
it("handles null input gracefully", () => {
const err = classifyUnrestrictError(null);
expect(err.kind).toBe(DownloadErrorKind.Unknown);
});
});
// ===========================================================================
// classifyExtractionError
// ===========================================================================
describe("classifyExtractionError", () => {
// ---- WrongPassword (permanent) ----
it("classifies 'wrong password' as WrongPassword", () => {
const err = classifyExtractionError("Wrong password for archive.rar");
expect(err.kind).toBe(DownloadErrorKind.WrongPassword);
expect(err.permanent).toBe(true);
expect(err.retryable).toBe(false);
});
it("classifies 'falsches Passwort' as WrongPassword", () => {
const err = classifyExtractionError("Falsches Passwort eingegeben");
expect(err.kind).toBe(DownloadErrorKind.WrongPassword);
expect(err.permanent).toBe(true);
});
it("classifies category 'wrong_password' as WrongPassword even with generic message", () => {
const err = classifyExtractionError("extraction error", "wrong_password");
expect(err.kind).toBe(DownloadErrorKind.WrongPassword);
expect(err.permanent).toBe(true);
});
// ---- ArchiveCorrupt ----
it.each([
"archive is corrupt",
"unexpected end of archive",
"broken header in rar",
"invalid archive format",
"bad signature in header",
"Archiv beschädigt",
])("classifies '%s' as ArchiveCorrupt", (msg) => {
const err = classifyExtractionError(msg);
expect(err.kind).toBe(DownloadErrorKind.ArchiveCorrupt);
expect(err.retryable).toBe(true);
});
it("classifies category 'archive_corrupt' as ArchiveCorrupt", () => {
const err = classifyExtractionError("some error", "archive_corrupt");
expect(err.kind).toBe(DownloadErrorKind.ArchiveCorrupt);
});
// ---- ExtractorCrash ----
it.each([
"process exited with code 1",
"process crashed unexpectedly",
"extractor failed to start",
"Segmentation fault (core dumped)",
])("classifies '%s' as ExtractorCrash", (msg) => {
const err = classifyExtractionError(msg);
expect(err.kind).toBe(DownloadErrorKind.ExtractorCrash);
expect(err.retryable).toBe(true);
});
it("classifies category 'extractor_crash' as ExtractorCrash", () => {
const err = classifyExtractionError("unknown", "extractor_crash");
expect(err.kind).toBe(DownloadErrorKind.ExtractorCrash);
});
// ---- DiskFull ----
it.each([
"ENOSPC: write failed",
"No space left on device",
])("classifies '%s' as DiskFull (permanent)", (msg) => {
const err = classifyExtractionError(msg);
expect(err.kind).toBe(DownloadErrorKind.DiskFull);
expect(err.permanent).toBe(true);
expect(err.retryable).toBe(false);
});
// ---- Unknown ----
it("classifies unrecognised extraction error as Unknown", () => {
const err = classifyExtractionError("some new error we haven't seen");
expect(err.kind).toBe(DownloadErrorKind.Unknown);
});
it("handles empty string input", () => {
const err = classifyExtractionError("");
expect(err.kind).toBe(DownloadErrorKind.Unknown);
});
});
// ===========================================================================
// ensureDownloadError
// ===========================================================================
describe("ensureDownloadError", () => {
it("returns existing DownloadError unchanged", () => {
const orig = new DownloadError(DownloadErrorKind.Timeout, "timed out");
const result = ensureDownloadError(orig);
expect(result).toBe(orig);
});
it("wraps a plain Error via classifyFetchError", () => {
const result = ensureDownloadError(new Error("ECONNRESET"));
expect(result).toBeInstanceOf(DownloadError);
expect(result.kind).toBe(DownloadErrorKind.NetworkReset);
});
it("wraps a string via classifyFetchError", () => {
const result = ensureDownloadError("ETIMEDOUT");
expect(result).toBeInstanceOf(DownloadError);
expect(result.kind).toBe(DownloadErrorKind.ConnectTimeout);
});
it("wraps null as Unknown", () => {
const result = ensureDownloadError(null);
expect(result).toBeInstanceOf(DownloadError);
expect(result.kind).toBe(DownloadErrorKind.Unknown);
});
it("re-throws abort errors (inherits classifyFetchError behavior)", () => {
expect(() => ensureDownloadError(new Error("abort"))).toThrow();
});
});
// ===========================================================================
// errorKindLabel
// ===========================================================================
describe("errorKindLabel", () => {
it("returns a non-empty string for every DownloadErrorKind", () => {
for (const kind of Object.values(DownloadErrorKind)) {
const label = errorKindLabel(kind);
expect(label).toBeTruthy();
expect(typeof label).toBe("string");
expect(label.length).toBeGreaterThan(0);
}
});
it("returns specific labels for known kinds", () => {
expect(errorKindLabel(DownloadErrorKind.NetworkReset)).toBe("Netzwerkfehler");
expect(errorKindLabel(DownloadErrorKind.DiskFull)).toBe("Festplatte voll");
expect(errorKindLabel(DownloadErrorKind.WrongPassword)).toBe("Falsches Archiv-Passwort");
expect(errorKindLabel(DownloadErrorKind.RateLimited)).toBe("Rate-Limit erreicht");
expect(errorKindLabel(DownloadErrorKind.Unknown)).toBe("Unbekannter Fehler");
});
it("falls back to 'Unbekannter Fehler' for an unrecognised kind", () => {
const label = errorKindLabel("made_up_kind" as DownloadErrorKind);
expect(label).toBe("Unbekannter Fehler");
});
});
// ===========================================================================
// isPermanentKind
// ===========================================================================
describe("isPermanentKind", () => {
it("returns true for LinkDead", () => {
expect(isPermanentKind(DownloadErrorKind.LinkDead)).toBe(true);
});
it("returns true for DiskFull", () => {
expect(isPermanentKind(DownloadErrorKind.DiskFull)).toBe(true);
});
it("returns true for PermissionDenied", () => {
expect(isPermanentKind(DownloadErrorKind.PermissionDenied)).toBe(true);
});
it("returns true for WrongPassword", () => {
expect(isPermanentKind(DownloadErrorKind.WrongPassword)).toBe(true);
});
it("returns false for retryable kinds", () => {
const retryableKinds = [
DownloadErrorKind.NetworkReset,
DownloadErrorKind.Timeout,
DownloadErrorKind.DnsFailure,
DownloadErrorKind.ConnectTimeout,
DownloadErrorKind.RangeNotSatisfied,
DownloadErrorKind.RangeIgnored,
DownloadErrorKind.ServerError,
DownloadErrorKind.RateLimited,
DownloadErrorKind.Forbidden,
DownloadErrorKind.NotFound,
DownloadErrorKind.UnrestrictFailed,
DownloadErrorKind.ProviderBusy,
DownloadErrorKind.ProviderDown,
DownloadErrorKind.HosterUnavailable,
DownloadErrorKind.QuotaExceeded,
DownloadErrorKind.FileLocked,
DownloadErrorKind.FileCorrupt,
DownloadErrorKind.FileTruncated,
DownloadErrorKind.ResumeUnderflow,
DownloadErrorKind.ArchiveCorrupt,
DownloadErrorKind.ExtractorCrash,
DownloadErrorKind.WriteDrainTimeout,
DownloadErrorKind.Unknown,
];
for (const kind of retryableKinds) {
expect(isPermanentKind(kind)).toBe(false);
}
});
});
// ===========================================================================
// Edge cases and priority
// ===========================================================================
describe("classifier priority / edge cases", () => {
it("classifyFetchError checks abort before other patterns", () => {
// "abort" appears before network patterns, so abort should win
expect(() => classifyFetchError(new Error("Aborted: ECONNRESET"))).toThrow();
});
it("classifyFetchError: ETIMEDOUT wins over ECONNRESET when both keywords present", () => {
// ConnectTimeout is checked before NetworkReset in the code
const err = classifyFetchError(new Error("ETIMEDOUT ECONNRESET"));
expect(err.kind).toBe(DownloadErrorKind.ConnectTimeout);
});
it("classifyFetchError: DNS checked before NetworkReset", () => {
const err = classifyFetchError(new Error("getaddrinfo ENOTFOUND fetch failed"));
expect(err.kind).toBe(DownloadErrorKind.DnsFailure);
});
it("classifyFetchError: ENOSPC checked before generic unknown", () => {
const err = classifyFetchError(new Error("write error ENOSPC"));
expect(err.kind).toBe(DownloadErrorKind.DiskFull);
});
it("classifyExtractionError: wrong_password category overrides message text", () => {
// Even if message contains 'corrupt', category should take priority
const err = classifyExtractionError("archive is corrupt", "wrong_password");
expect(err.kind).toBe(DownloadErrorKind.WrongPassword);
});
it("classifyHttpStatus: treats status 599 as ServerError (>= 500 rule)", () => {
const err = classifyHttpStatus({ status: 599 });
expect(err.kind).toBe(DownloadErrorKind.ServerError);
});
it("classifyHttpStatus: treats status 200 as Unknown", () => {
const err = classifyHttpStatus({ status: 200 });
expect(err.kind).toBe(DownloadErrorKind.Unknown);
});
});
+812
View File
@@ -0,0 +1,812 @@
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();
});
});