Critical fixes: - Post-processor: remove double attempts increment (onProgress + onArchiveFailure both counted) - Post-processor: fix slot leak when signal aborted after acquireSlot - Scheduler: reset global watchdog high-water mark after stall event (prevents permanent misfires) - Pipeline/DM: fix isPathInsideDir path traversal (add trailing separator check) - Retry-manager: check per-kind exhaustion before shelve threshold (prevents bypass) - Retry-manager: add MAX_SHELVE_COUNT=5 cap to prevent infinite shelve cycling Important fixes: - Scheduler: clear retryDelays and providerCooldowns on start() - Scheduler: skip already-aborting slots in stall detection - Download-manager: fix cleanupAfterExtraction using extractDir instead of outputDir for link removal - Download-manager: add "extracting" to package normalizeSessionStatuses - Download-manager: clear activeTasks map on stop() - Download-manager: remove useless cachedDirectUrls re-insertion after success - Stream-writer: remove duplicate truncation code in error path - Stream-writer: skip alignedFlush in finally when bodyError already set (avoids 5min drain wait) - Stream-writer: re-read elapsed after speed limiter sleep for accurate window reset - Error-classifier: add HTTP 401 (Forbidden) and 410 (NotFound) classification Tests updated to match new shelve/kind-exhaustion priority and 401 classification. All 216 tests pass, build verified. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
519 lines
16 KiB
TypeScript
519 lines
16 KiB
TypeScript
/**
|
|
* error-classifier.ts — Typed error system for download pipeline.
|
|
*
|
|
* Every error gets classified ONCE at the point of origin into a
|
|
* DownloadErrorKind. No post-hoc string matching needed downstream.
|
|
*/
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Error Kinds
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export enum DownloadErrorKind {
|
|
// Network
|
|
NetworkReset = "network_reset",
|
|
Timeout = "timeout",
|
|
DnsFailure = "dns_failure",
|
|
ConnectTimeout = "connect_timeout",
|
|
|
|
// HTTP
|
|
RangeNotSatisfied = "range_not_satisfied",
|
|
RangeIgnored = "range_ignored",
|
|
ServerError = "server_error",
|
|
RateLimited = "rate_limited",
|
|
Forbidden = "forbidden",
|
|
NotFound = "not_found",
|
|
|
|
// Provider / Debrid
|
|
UnrestrictFailed = "unrestrict_failed",
|
|
ProviderBusy = "provider_busy",
|
|
ProviderDown = "provider_down",
|
|
HosterUnavailable = "hoster_unavailable",
|
|
LinkDead = "link_dead",
|
|
QuotaExceeded = "quota_exceeded",
|
|
|
|
// Filesystem
|
|
DiskFull = "disk_full",
|
|
PermissionDenied = "permission_denied",
|
|
FileLocked = "file_locked",
|
|
|
|
// Integrity / Resume
|
|
FileCorrupt = "file_corrupt",
|
|
FileTruncated = "file_truncated",
|
|
ResumeUnderflow = "resume_underflow",
|
|
|
|
// Extraction
|
|
WrongPassword = "wrong_password",
|
|
ArchiveCorrupt = "archive_corrupt",
|
|
ExtractorCrash = "extractor_crash",
|
|
|
|
// Write / Drain
|
|
WriteDrainTimeout = "write_drain_timeout",
|
|
|
|
// Catchall
|
|
Unknown = "unknown",
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Permanent kinds — retrying is pointless
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const PERMANENT_KINDS = new Set<DownloadErrorKind>([
|
|
DownloadErrorKind.LinkDead,
|
|
DownloadErrorKind.DiskFull,
|
|
DownloadErrorKind.PermissionDenied,
|
|
DownloadErrorKind.WrongPassword,
|
|
]);
|
|
|
|
export function isPermanentKind(kind: DownloadErrorKind): boolean {
|
|
return PERMANENT_KINDS.has(kind);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DownloadError class
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export class DownloadError extends Error {
|
|
readonly kind: DownloadErrorKind;
|
|
readonly retryable: boolean;
|
|
readonly permanent: boolean;
|
|
readonly httpStatus?: number;
|
|
readonly originalError?: Error;
|
|
/** Extra context (e.g. existing bytes, expected total). */
|
|
readonly context?: Record<string, unknown>;
|
|
|
|
constructor(
|
|
kind: DownloadErrorKind,
|
|
message: string,
|
|
opts?: {
|
|
httpStatus?: number;
|
|
originalError?: Error;
|
|
retryable?: boolean;
|
|
permanent?: boolean;
|
|
context?: Record<string, unknown>;
|
|
},
|
|
) {
|
|
super(message);
|
|
this.name = "DownloadError";
|
|
this.kind = kind;
|
|
this.retryable = opts?.retryable ?? !isPermanentKind(kind);
|
|
this.permanent = opts?.permanent ?? isPermanentKind(kind);
|
|
this.httpStatus = opts?.httpStatus;
|
|
this.originalError = opts?.originalError;
|
|
this.context = opts?.context;
|
|
}
|
|
|
|
/** Compact single-line representation for logging. */
|
|
toLogString(): string {
|
|
const parts = [`[${this.kind}]`, this.message];
|
|
if (this.httpStatus) parts.push(`(HTTP ${this.httpStatus})`);
|
|
return parts.join(" ");
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Classifier: raw fetch / network errors
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function classifyFetchError(error: unknown): DownloadError {
|
|
const text = errorText(error);
|
|
const lc = text.toLowerCase();
|
|
|
|
// Abort is not an error to classify — re-throw as-is
|
|
if (lc.includes("aborted:") || lc.includes("abort")) {
|
|
// Preserve abort errors unchanged so callers can check abortReason
|
|
throw error instanceof Error ? error : new Error(text);
|
|
}
|
|
|
|
// Connection timeout
|
|
if (lc.includes("connect_timeout") || lc.includes("etimedout") || lc.includes("connection timed out")) {
|
|
return new DownloadError(DownloadErrorKind.ConnectTimeout, text, {
|
|
originalError: toError(error),
|
|
});
|
|
}
|
|
|
|
// DNS
|
|
if (lc.includes("enotfound") || lc.includes("getaddrinfo") || lc.includes("dns")) {
|
|
return new DownloadError(DownloadErrorKind.DnsFailure, text, {
|
|
originalError: toError(error),
|
|
});
|
|
}
|
|
|
|
// Network reset
|
|
if (
|
|
lc.includes("fetch failed") ||
|
|
lc.includes("socket hang up") ||
|
|
lc.includes("econnreset") ||
|
|
lc.includes("econnrefused") ||
|
|
lc.includes("epipe") ||
|
|
lc.includes("network error") ||
|
|
lc.includes("econnaborted") ||
|
|
lc.includes("socket closed") ||
|
|
lc.includes("connection reset")
|
|
) {
|
|
return new DownloadError(DownloadErrorKind.NetworkReset, text, {
|
|
originalError: toError(error),
|
|
});
|
|
}
|
|
|
|
// Stall / read timeout
|
|
if (lc.includes("stall_timeout") || lc.includes("read timeout")) {
|
|
return new DownloadError(DownloadErrorKind.Timeout, text, {
|
|
originalError: toError(error),
|
|
});
|
|
}
|
|
|
|
// Write drain timeout
|
|
if (lc.includes("write_drain_timeout")) {
|
|
return new DownloadError(DownloadErrorKind.WriteDrainTimeout, text, {
|
|
originalError: toError(error),
|
|
});
|
|
}
|
|
|
|
// Disk full
|
|
if (lc.includes("enospc") || lc.includes("no space left")) {
|
|
return new DownloadError(DownloadErrorKind.DiskFull, text, {
|
|
originalError: toError(error),
|
|
permanent: true,
|
|
});
|
|
}
|
|
|
|
// Permission denied
|
|
if (lc.includes("eacces") || lc.includes("eperm") || lc.includes("permission denied")) {
|
|
return new DownloadError(DownloadErrorKind.PermissionDenied, text, {
|
|
originalError: toError(error),
|
|
permanent: true,
|
|
});
|
|
}
|
|
|
|
// File locked (Windows)
|
|
if (lc.includes("ebusy") || lc.includes("file is locked") || lc.includes("being used by another process")) {
|
|
return new DownloadError(DownloadErrorKind.FileLocked, text, {
|
|
originalError: toError(error),
|
|
});
|
|
}
|
|
|
|
// Resume underflow
|
|
if (lc.startsWith("resume_download_underflow:")) {
|
|
return new DownloadError(DownloadErrorKind.ResumeUnderflow, text, {
|
|
originalError: toError(error),
|
|
});
|
|
}
|
|
|
|
// Range ignored on resume
|
|
if (lc.startsWith("range_ignored_on_resume:")) {
|
|
return new DownloadError(DownloadErrorKind.RangeIgnored, text, {
|
|
originalError: toError(error),
|
|
});
|
|
}
|
|
|
|
return new DownloadError(DownloadErrorKind.Unknown, text, {
|
|
originalError: toError(error),
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Classifier: HTTP response status codes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface HttpClassifyContext {
|
|
status: number;
|
|
statusText?: string;
|
|
responseText?: string;
|
|
existingBytes?: number;
|
|
rangeHeaderSent?: boolean;
|
|
}
|
|
|
|
export function classifyHttpStatus(ctx: HttpClassifyContext): DownloadError {
|
|
const { status, statusText, responseText } = ctx;
|
|
const body = responseText || statusText || "";
|
|
const msg = `HTTP ${status}${body ? ": " + compactText(body) : ""}`;
|
|
|
|
switch (true) {
|
|
case status === 416:
|
|
return new DownloadError(DownloadErrorKind.RangeNotSatisfied, msg, {
|
|
httpStatus: status,
|
|
context: { existingBytes: ctx.existingBytes },
|
|
});
|
|
|
|
case status === 429:
|
|
return new DownloadError(DownloadErrorKind.RateLimited, msg, {
|
|
httpStatus: status,
|
|
});
|
|
|
|
case status === 401:
|
|
return new DownloadError(DownloadErrorKind.Forbidden, msg, {
|
|
httpStatus: status,
|
|
});
|
|
|
|
case status === 403:
|
|
return new DownloadError(DownloadErrorKind.Forbidden, msg, {
|
|
httpStatus: status,
|
|
});
|
|
|
|
case status === 404:
|
|
return new DownloadError(DownloadErrorKind.NotFound, msg, {
|
|
httpStatus: status,
|
|
});
|
|
|
|
case status === 410:
|
|
return new DownloadError(DownloadErrorKind.NotFound, msg, {
|
|
httpStatus: status,
|
|
});
|
|
|
|
case status >= 500:
|
|
return new DownloadError(DownloadErrorKind.ServerError, msg, {
|
|
httpStatus: status,
|
|
});
|
|
|
|
default:
|
|
return new DownloadError(DownloadErrorKind.Unknown, msg, {
|
|
httpStatus: status,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detect when the server ignored a Range header (sent 200 instead of 206).
|
|
* Call this AFTER receiving a 200 response when a Range header was sent.
|
|
*/
|
|
export function classifyRangeIgnored(
|
|
existingBytes: number,
|
|
contentLength: number,
|
|
): DownloadError {
|
|
return new DownloadError(
|
|
DownloadErrorKind.RangeIgnored,
|
|
`range_ignored_on_resume:${existingBytes}/${contentLength}`,
|
|
{ context: { existingBytes, contentLength } },
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Classifier: unrestrict / debrid API errors
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function classifyUnrestrictError(error: unknown): DownloadError {
|
|
const text = errorText(error);
|
|
const lc = text.toLowerCase();
|
|
|
|
// Permanent: file is dead
|
|
if (
|
|
lc.includes("permanent ungültig") ||
|
|
/file.?not.?found/.test(lc) ||
|
|
/file.?unavailable/.test(lc) ||
|
|
/link.?is.?dead/.test(lc) ||
|
|
lc.includes("file has been removed") ||
|
|
lc.includes("file has been deleted") ||
|
|
lc.includes("file is no longer available") ||
|
|
lc.includes("file was removed") ||
|
|
lc.includes("file was deleted")
|
|
) {
|
|
return new DownloadError(DownloadErrorKind.LinkDead, text, {
|
|
originalError: toError(error),
|
|
permanent: true,
|
|
});
|
|
}
|
|
|
|
// Provider busy / concurrent limit
|
|
if (
|
|
lc.includes("too many active") ||
|
|
lc.includes("too many concurrent") ||
|
|
lc.includes("too many downloads") ||
|
|
lc.includes("active download") ||
|
|
lc.includes("concurrent limit") ||
|
|
lc.includes("slot limit") ||
|
|
lc.includes("limit reached") ||
|
|
lc.includes("zu viele aktive") ||
|
|
lc.includes("zu viele gleichzeitige") ||
|
|
lc.includes("zu viele downloads")
|
|
) {
|
|
return new DownloadError(DownloadErrorKind.ProviderBusy, text, {
|
|
originalError: toError(error),
|
|
});
|
|
}
|
|
|
|
// Hoster unavailable
|
|
if (lc.includes("hosternotavailable")) {
|
|
return new DownloadError(DownloadErrorKind.HosterUnavailable, text, {
|
|
originalError: toError(error),
|
|
});
|
|
}
|
|
|
|
// Quota / traffic exceeded
|
|
if (
|
|
lc.includes("quota") ||
|
|
lc.includes("traffic") ||
|
|
lc.includes("bandwidth limit") ||
|
|
lc.includes("daily limit")
|
|
) {
|
|
return new DownloadError(DownloadErrorKind.QuotaExceeded, text, {
|
|
originalError: toError(error),
|
|
});
|
|
}
|
|
|
|
// Provider temporarily down
|
|
if (
|
|
lc.includes("server error") ||
|
|
lc.includes("internal server error") ||
|
|
lc.includes("temporarily unavailable") ||
|
|
lc.includes("temporary unavailable") ||
|
|
lc.includes("temporarily disabled") ||
|
|
lc.includes("try again later") ||
|
|
lc.includes("service unavailable") ||
|
|
lc.includes("host is down") ||
|
|
lc.includes("maintenance") ||
|
|
lc.includes("bad gateway") ||
|
|
lc.includes("gateway timeout") ||
|
|
lc.includes("cloudflare") ||
|
|
lc.includes("worker error")
|
|
) {
|
|
return new DownloadError(DownloadErrorKind.ProviderDown, text, {
|
|
originalError: toError(error),
|
|
});
|
|
}
|
|
|
|
// Generic unrestrict failure (session, login, etc.)
|
|
if (
|
|
lc.includes("unrestrict") ||
|
|
lc.includes("mega-web") ||
|
|
lc.includes("mega-debrid") ||
|
|
lc.includes("bestdebrid") ||
|
|
lc.includes("alldebrid") ||
|
|
lc.includes("kein debrid") ||
|
|
lc.includes("session-cookie") ||
|
|
lc.includes("session cookie") ||
|
|
lc.includes("session blockiert") ||
|
|
lc.includes("session expired") ||
|
|
lc.includes("invalid session") ||
|
|
lc.includes("login ungültig") ||
|
|
lc.includes("login liefert") ||
|
|
lc.includes("login required") ||
|
|
lc.includes("login failed")
|
|
) {
|
|
return new DownloadError(DownloadErrorKind.UnrestrictFailed, text, {
|
|
originalError: toError(error),
|
|
});
|
|
}
|
|
|
|
return new DownloadError(DownloadErrorKind.Unknown, text, {
|
|
originalError: toError(error),
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Classifier: extraction errors
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function classifyExtractionError(
|
|
errorText_: string,
|
|
category?: string,
|
|
): DownloadError {
|
|
const lc = (errorText_ || "").toLowerCase();
|
|
|
|
if (lc.includes("wrong password") || lc.includes("falsches passwort") || category === "wrong_password") {
|
|
return new DownloadError(DownloadErrorKind.WrongPassword, errorText_, {
|
|
permanent: true,
|
|
});
|
|
}
|
|
|
|
if (
|
|
lc.includes("corrupt") ||
|
|
lc.includes("unexpected end") ||
|
|
lc.includes("broken header") ||
|
|
lc.includes("invalid archive") ||
|
|
lc.includes("bad signature") ||
|
|
lc.includes("beschädigt") ||
|
|
category === "archive_corrupt"
|
|
) {
|
|
return new DownloadError(DownloadErrorKind.ArchiveCorrupt, errorText_);
|
|
}
|
|
|
|
if (
|
|
lc.includes("process exited") ||
|
|
lc.includes("process crashed") ||
|
|
lc.includes("extractor failed") ||
|
|
lc.includes("segmentation fault") ||
|
|
category === "extractor_crash"
|
|
) {
|
|
return new DownloadError(DownloadErrorKind.ExtractorCrash, errorText_);
|
|
}
|
|
|
|
if (lc.includes("enospc") || lc.includes("no space left")) {
|
|
return new DownloadError(DownloadErrorKind.DiskFull, errorText_, {
|
|
permanent: true,
|
|
});
|
|
}
|
|
|
|
return new DownloadError(DownloadErrorKind.Unknown, errorText_);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Convenience: wrap any unknown error into a DownloadError
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Ensure any thrown value becomes a DownloadError.
|
|
* If already a DownloadError, return as-is.
|
|
*/
|
|
export function ensureDownloadError(error: unknown): DownloadError {
|
|
if (error instanceof DownloadError) return error;
|
|
return classifyFetchError(error);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Human-readable error messages for UI
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const KIND_LABELS: Record<DownloadErrorKind, string> = {
|
|
[DownloadErrorKind.NetworkReset]: "Netzwerkfehler",
|
|
[DownloadErrorKind.Timeout]: "Zeitüberschreitung",
|
|
[DownloadErrorKind.DnsFailure]: "DNS-Fehler",
|
|
[DownloadErrorKind.ConnectTimeout]: "Verbindungs-Timeout",
|
|
[DownloadErrorKind.RangeNotSatisfied]: "Range-Konflikt (HTTP 416)",
|
|
[DownloadErrorKind.RangeIgnored]: "Server ignorierte Resume",
|
|
[DownloadErrorKind.ServerError]: "Serverfehler",
|
|
[DownloadErrorKind.RateLimited]: "Rate-Limit erreicht",
|
|
[DownloadErrorKind.Forbidden]: "Zugriff verweigert",
|
|
[DownloadErrorKind.NotFound]: "Nicht gefunden",
|
|
[DownloadErrorKind.UnrestrictFailed]: "Unrestrict fehlgeschlagen",
|
|
[DownloadErrorKind.ProviderBusy]: "Provider ausgelastet",
|
|
[DownloadErrorKind.ProviderDown]: "Provider nicht erreichbar",
|
|
[DownloadErrorKind.HosterUnavailable]: "Hoster nicht verfügbar",
|
|
[DownloadErrorKind.LinkDead]: "Link ungültig / gelöscht",
|
|
[DownloadErrorKind.QuotaExceeded]: "Tages-Limit erreicht",
|
|
[DownloadErrorKind.DiskFull]: "Festplatte voll",
|
|
[DownloadErrorKind.PermissionDenied]: "Zugriff verweigert (Dateisystem)",
|
|
[DownloadErrorKind.FileLocked]: "Datei gesperrt",
|
|
[DownloadErrorKind.FileCorrupt]: "Datei beschädigt (CRC-Fehler)",
|
|
[DownloadErrorKind.FileTruncated]: "Download unvollständig",
|
|
[DownloadErrorKind.ResumeUnderflow]: "Resume-Fehler",
|
|
[DownloadErrorKind.WrongPassword]: "Falsches Archiv-Passwort",
|
|
[DownloadErrorKind.ArchiveCorrupt]: "Archiv beschädigt",
|
|
[DownloadErrorKind.ExtractorCrash]: "Entpacker abgestürzt",
|
|
[DownloadErrorKind.WriteDrainTimeout]: "Schreibvorgang blockiert",
|
|
[DownloadErrorKind.Unknown]: "Unbekannter Fehler",
|
|
};
|
|
|
|
export function errorKindLabel(kind: DownloadErrorKind): string {
|
|
return KIND_LABELS[kind] || KIND_LABELS[DownloadErrorKind.Unknown];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internal helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function errorText(e: unknown): string {
|
|
if (typeof e === "string") return e;
|
|
if (e instanceof Error) return e.message || String(e);
|
|
return String(e ?? "");
|
|
}
|
|
|
|
function toError(e: unknown): Error {
|
|
if (e instanceof Error) return e;
|
|
return new Error(String(e ?? ""));
|
|
}
|
|
|
|
function compactText(s: string): string {
|
|
return s.replace(/\s+/g, " ").trim().slice(0, 200);
|
|
}
|