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