From 93a85a0255217f928fe9c88b4381d8088ab1cbbb Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Tue, 23 Jun 2026 16:12:52 +0200 Subject: [PATCH] HTTP 416: erschoepftes Range-Konflikt-Item macht bounded Clean-Restart statt dauerhaft zu haengen Nutzer-Report (ultracode-Diagnose, 6-Agenten): ein Item mit "Fehler: HTTP 416" (Range Not Satisfiable) wurde gar nicht mehr erneut versucht und blockierte das ganze Paket ("Entpacken - Ausstehend" bei allen fertigen Geschwistern). Befund code-belegt: nach dem In-Run-416-Budget (maxHttp416Retries) lief das Item in einen Endzustand: - retryLimit>0 (bare "HTTP 416", directLinkRetryMatch=false): direkt terminal "failed" (download-manager ~9442). Der einzige Re-Retry-Pfad recoverRetryableItems() laeuft NUR bei Boot/Start, nie mid-run -> mit autoReconnect=false bleibt es bis zum Neustart "failed". Und da jeder Extract-Zweig auf failed===0 gated, friert EIN failed-Item das ganze Paket ein -> exakt das Screenshot-Symptom. - retryLimit=0 (wrapped "direct_link_retry_exhausted:HTTP 416"): faellt nach dem 416-Budget in den generischen Retry (9349) mit maxGenericErrorRetries= MAX_SAFE_INTEGER -> unbegrenzte futile Re-Unrestricts (haemmert den Hoster, Item kommt nie weiter). Fix: bei erschoepftem 416-Budget an BEIDEN Stellen (gewrappt @9320, bare @9442) nicht mehr terminalisieren bzw. endlos generisch retrien, sondern via neuem escalateHttp416OrFail() einen kompletten Neu-Download von 0 anstossen: Partial loeschen (rmSync), Bytes/Progress/Budget zuruecksetzen, mit Delay re-queuen. Da 416 ein abgelehnter Resume-Range ist, ist der korrekte Weg ein frischer Download ohne Range-Header (kein 416 mehr) statt erneuter Resume. Hart gedeckelt ueber http416FreshRestartByItem (MAX_HTTP416_FRESH_RESTARTS=2, retryLimit- unabhaengig) -> ein wirklich toter Link faellt nach begrenzten sauberen Neustarts terminal, kein Endlos-Hammering. Zaehler wird in start() und beim terminalen Fail geleert. Fresh-Restart-Delay via RD_HTTP416_FRESH_RESTART_DELAY_MS testbar. Sobald das Item den failed-Zustand verlaesst, entsperrt die bestehende failed===0-Logik das Paket-Entpacken von selbst. (Die optionale Haertung "fertige Archiv-Sets trotz failed-Geschwister entpacken" + "Voll-Datei-416 als fertig werten statt neu laden" sind bewusst als Folgeschritt offen.) Tests: Recovery (416 erschoepft -> Clean-Restart -> Item completed, retryLimit>0 bare) + Bounded-Fail (persistentes 416 -> begrenzte Neustarts -> failed, retryLimit=0 wrapped, terminiert statt Endlos). Suite 945 gruen, tsc=6. --- src/main/download-manager.ts | 85 +++++++++++++++++++++++++------ tests/download-manager.test.ts | 92 ++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 15 deletions(-) diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index d782876..36bf558 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -131,6 +131,17 @@ const ARCHIVE_SETTLE_MAX_WAIT_MS = 5000; const MAX_SAME_DIRECT_URL_ATTEMPTS = 3; +const MAX_HTTP416_FRESH_RESTARTS = 2; +const HTTP416_FRESH_RESTART_DELAY_MS = 8000; + +function getHttp416FreshRestartDelayMs(): number { + const fromEnv = Number(process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS ?? NaN); + if (Number.isFinite(fromEnv) && fromEnv >= 0 && fromEnv <= 600000) { + return Math.floor(fromEnv); + } + return HTTP416_FRESH_RESTART_DELAY_MS; +} + const RESUME_REWIND_BYTES = 256 * 1024; const REALDEBRID_TOTAL_MISMATCH_TOLERANCE_BYTES = 64 * 1024; @@ -1829,6 +1840,8 @@ export class DownloadManager extends EventEmitter { unrestrictRetries: number; }>(); + private http416FreshRestartByItem = new Map(); + private providerFailures = new Map(); private allDebridHostInfoCache = new Map(); @@ -5678,6 +5691,7 @@ export class DownloadManager extends EventEmitter { this.providerStartReservations.clear(); this.pacedStartReservationByItem.clear(); this.retryStateByItem.clear(); + this.http416FreshRestartByItem.clear(); this.itemContributedBytes.clear(); this.reservedTargetPaths.clear(); this.claimedTargetPathByItem.clear(); @@ -8727,6 +8741,53 @@ export class DownloadManager extends EventEmitter { this.queueRetry(item, active, delayMs, `HTTP 416 erkannt, Retry ${active.genericErrorRetries}/${retryDisplayLimit}`); } + private escalateHttp416OrFail(item: DownloadItem, active: ActiveTask, claimedTargetPath: string, errorText: string): void { + const freshRestarts = this.http416FreshRestartByItem.get(item.id) || 0; + if (freshRestarts < MAX_HTTP416_FRESH_RESTARTS) { + this.http416FreshRestartByItem.set(item.id, freshRestarts + 1); + const resetTargetPath = claimedTargetPath || String(item.targetPath || "").trim(); + if (resetTargetPath) { + try { + fs.rmSync(resetTargetPath, { force: true }); + } catch { + } + } + this.releaseTargetPath(item.id); + this.dropItemContribution(item.id); + item.retries += 1; + item.downloadedBytes = 0; + item.totalBytes = null; + item.progressPercent = 0; + item.speedBps = 0; + item.lastError = ""; + active.genericErrorRetries = 0; + active.freshRetryUsed = false; + active.resumeHardResetUsed = false; + logger.warn( + `HTTP 416 Budget erschöpft: item=${item.fileName || item.id}, ` + + `kompletter Neu-Download ${freshRestarts + 1}/${MAX_HTTP416_FRESH_RESTARTS} (Partial verworfen, kein Resume), provider=${item.provider || "?"}` + ); + this.queueRetry(item, active, getHttp416FreshRestartDelayMs(), `Range-Konflikt (HTTP 416): Neu-Download ${freshRestarts + 1}/${MAX_HTTP416_FRESH_RESTARTS}`); + this.persistSoon(); + this.emitState(); + return; + } + this.http416FreshRestartByItem.delete(item.id); + item.status = "failed"; + this.recordRunOutcome(item.id, "failed"); + item.lastError = errorText; + item.fullStatus = `Fehler: ${item.lastError}`; + item.speedBps = 0; + item.updatedAt = nowMs(); + const failPkg = this.session.packages[item.packageId]; + if (failPkg) { + this.refreshPackageStatus(failPkg); + } + this.persistSoon(); + this.emitState(); + this.retryStateByItem.delete(item.id); + } + private startItem(packageId: string, itemId: string): void { const item = this.session.items[itemId]; const pkg = this.session.packages[packageId]; @@ -9317,10 +9378,14 @@ export class DownloadManager extends EventEmitter { return; } } - if (isHttp416Text(exhaustedReason) && active.genericErrorRetries < maxHttp416Retries) { - this.scheduleHttp416Retry(item, active, retryDisplayLimit, exhaustedReason, claimedTargetPath); - this.persistSoon(); - this.emitState(); + if (isHttp416Text(exhaustedReason)) { + if (active.genericErrorRetries < maxHttp416Retries) { + this.scheduleHttp416Retry(item, active, retryDisplayLimit, exhaustedReason, claimedTargetPath); + this.persistSoon(); + this.emitState(); + return; + } + this.escalateHttp416OrFail(item, active, claimedTargetPath, exhaustedReason); return; } if (isResumeHardResetReason(exhaustedReason) && !active.resumeHardResetUsed) { @@ -9377,17 +9442,7 @@ export class DownloadManager extends EventEmitter { this.emitState(); return; } - item.status = "failed"; - this.recordRunOutcome(item.id, "failed"); - item.lastError = errorText; - item.fullStatus = `Fehler: ${item.lastError}`; - item.speedBps = 0; - item.updatedAt = nowMs(); - const failPkg416 = this.session.packages[item.packageId]; - if (failPkg416) this.refreshPackageStatus(failPkg416); - this.persistSoon(); - this.emitState(); - this.retryStateByItem.delete(item.id); + this.escalateHttp416OrFail(item, active, claimedTargetPath, errorText); return; } if (shouldFreshRetry) { diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 3999473..0bf7215 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -2209,6 +2209,98 @@ describe("download manager", () => { expect(fs.statSync(item.targetPath).size).toBe(binary.length); }); + it("recovers an HTTP 416 item with a clean fresh restart after the in-budget retries are exhausted", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-416-fresh-")); + tempDirs.push(root); + const binary = Buffer.alloc(160 * 1024, 19); + const prevDelay = process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; + process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = "0"; + let downloadCalls = 0; + + globalThis.fetch = async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response(JSON.stringify({ download: "https://dummy/direct-416-recover", filename: "fresh-416.mkv", filesize: binary.length }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + throw new Error(`unexpected fetch ${url}`); + }; + + try { + const manager = new DownloadManager( + { ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), retryLimit: 2, autoExtract: false, autoReconnect: false }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + (manager as any).downloadToFile = async (_active: unknown, _directUrl: string, targetPath: string) => { + downloadCalls += 1; + if (downloadCalls <= 3) { + throw new Error("HTTP 416"); + } + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, binary); + const item = Object.values((manager as any).session.items)[0] as { downloadedBytes: number; totalBytes: number; progressPercent: number } | undefined; + if (item) { + item.downloadedBytes = binary.length; + item.totalBytes = binary.length; + item.progressPercent = 100; + } + return { resumable: true }; + }; + + manager.addPackages([{ name: "fresh-416", links: ["https://dummy/fresh-416"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 20000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(downloadCalls).toBeGreaterThan(3); + } finally { + if (prevDelay === undefined) { delete process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; } else { process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = prevDelay; } + } + }, 25000); + + it("bounds HTTP 416 clean restarts and finally fails instead of looping forever or stalling permanently", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-416-cap-")); + tempDirs.push(root); + const prevDelay = process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; + process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = "0"; + let downloadCalls = 0; + + globalThis.fetch = async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response(JSON.stringify({ download: "https://dummy/direct-416-forever", filename: "always-416.mkv", filesize: 1024 }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + throw new Error(`unexpected fetch ${url}`); + }; + + try { + const manager = new DownloadManager( + { ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), retryLimit: 0, autoExtract: false, autoReconnect: false }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + (manager as any).downloadToFile = async () => { + downloadCalls += 1; + throw new Error("direct_link_retry_exhausted:HTTP 416"); + }; + + manager.addPackages([{ name: "always-416", links: ["https://dummy/always-416"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 20000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("failed"); + expect(downloadCalls).toBeGreaterThan(4); + expect(downloadCalls).toBeLessThan(30); + expect((manager as any).http416FreshRestartByItem.get(item.id)).toBeUndefined(); + } finally { + if (prevDelay === undefined) { delete process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; } else { process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = prevDelay; } + } + }, 25000); + it("retries HTTP 416 in-session when using Debrid-Link API and then completes", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root);