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:
co-authored by
Claude Opus 4.6
parent
63b412a43f
commit
efa0909e11
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user