beta-real-debrid-downloader/src/main/download/error-classifier.ts
Sucukdeluxe d0885ba552 Fix 16 bugs found by code review across all download modules
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>
2026-03-08 18:33:06 +01:00

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);
}