/** * 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.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; constructor( kind: DownloadErrorKind, message: string, opts?: { httpStatus?: number; originalError?: Error; retryable?: boolean; permanent?: boolean; context?: Record; }, ) { 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 === 403: return new DownloadError(DownloadErrorKind.Forbidden, msg, { httpStatus: status, }); case status === 404: 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.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); }