From dc13bd071b82b769d7b21422c59971ba08f5ba32 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Thu, 17 Sep 2026 17:38:25 +0200 Subject: [PATCH] fix: abgebrochene Video-Uploads werden als das gemeldet, was sie sind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Video-Upload: unerwarteter Fehler. Error: aborted { code: ECONNRESET }" verdeckte die eigentliche Aussage: nicht die Verarbeitung ist gescheitert, die Datei ist gar nicht erst vollstaendig angekommen. Der Fall landete in der allgemeinen Auffanglinie und sah damit aus wie ein Programmfehler. Ein Abbruch der Verbindung wird jetzt erkannt und mit Mengenangabe geloggt: [upload-video] Verbindung abgebrochen nach 41943040 von angekuendigten 78643200 Bytes. Genau diese Zahlen trennen die beiden moeglichen Ursachen. Bricht es immer bei derselben BYTEZAHL ab, liegt eine Groessengrenze auf dem Weg. Bricht es nach derselben ZEIT ab, ist es eine Zeitgrenze — und davon liegen mehrere hintereinander, allen voran Node selbst: dessen requestTimeout beendet einen Request nach fuenf Minuten und laesst sich nicht konfigurieren. Das ist die harte Obergrenze fuer die Dauer eines Uploads, unabhaengig davon, dass MAX_UPLOAD_VIDEO_BYTES 100 MB erlaubt. Statt eines 500ers antwortet die Route mit 408 und einer Meldung, die zum Wiederholen und zum vorherigen Verkleinern raet. Die Antwort erreicht die Kundschaft meist nicht mehr — die Verbindung ist ja weg —, ist aber richtig fuer den Fall, dass nur eine Zwischenstation aufgegeben hat. k8s/README.md bekommt dazu einen Abschnitt mit der Tabelle der Zeitgrenzen und dem ffmpeg-Aufruf, mit dem sich ein Hero-Video vorab auf genau das Mass bringen laesst, auf das der Server es ohnehin umrechnet. Was darueber hinausgeht, wird nur uebertragen, um anschliessend weggerechnet zu werden. Co-Authored-By: Claude Opus 5 (1M context) --- k8s/README.md | 40 ++++++++++++++++++++ src/app/api/admin/upload-video/route.ts | 50 +++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/k8s/README.md b/k8s/README.md index f7fab7a..44953c0 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -288,6 +288,46 @@ Kubernetes-Version), PVC und PV löschen und neu anlegen. Bei `persistentVolumeReclaimPolicy: Retain` bleiben die Daten auf der Freigabe erhalten. +## Video-Upload bricht ab (`ECONNRESET`) + +Symptom im Pod-Log: + +``` +[upload-video] Verbindung abgebrochen nach 41943040 von angekündigten 78643200 Bytes. +``` + +Das ist kein Fehler der Verarbeitung — die Datei ist gar nicht erst +vollständig angekommen. Die Zahlen im Log entscheiden, wo zu suchen ist: + +- **Immer bei ungefähr derselben BYTEZAHL** → eine Größengrenze auf dem Weg. + Traefik begrenzt von sich aus nichts; prüfen, ob eine `buffering`-Middleware + mit `maxRequestBodyBytes` am Ingress hängt. +- **Immer nach ungefähr derselben ZEIT** → eine Zeitgrenze. Davon liegen + mehrere hintereinander: + +| Grenze | Vorgabe | Wo einstellbar | +|--------|---------|----------------| +| Node `requestTimeout` | **300 s** | nicht konfigurierbar — harte Obergrenze für die Dauer eines Uploads | +| Traefik `respondingTimeouts.readTimeout` | je nach Version | `entryPoints.websecure.transport.respondingTimeouts` in der Traefik-Konfiguration | +| Browser bzw. Netz dazwischen | — | — | + +Die 300 Sekunden von Node sind die eigentliche Obergrenze: Was in dieser Zeit +nicht durch die Leitung passt, lässt sich nicht hochladen — unabhängig davon, +dass `MAX_UPLOAD_VIDEO_BYTES` 100 MB erlaubt. Beispiel: 80 MB über eine +Upload-Leitung mit 5 Mbit/s brauchen rund 130 Sekunden und gehen durch; bei +2 Mbit/s sind es über 5 Minuten, und der Upload stirbt zuverlässig. + +Praktisch heißt das: **das Hero-Video vor dem Hochladen verkleinern.** Der +Server rechnet es ohnehin auf höchstens 1920 px Breite und CRF 26 um (siehe +`src/lib/video-transcode.ts`) — was darüber hinausgeht, wird nur übertragen, +um anschließend weggerechnet zu werden. Ein vorab auf diese Größe gebrachtes +Video lädt um ein Vielfaches schneller hoch: + +```bash +ffmpeg -i original.mp4 -an -vf "scale='min(1920,iw)':-2" \ + -c:v libx264 -preset veryfast -crf 26 -movflags +faststart hero.mp4 +``` + ## Sicherung und Wiederherstellung `07-backup-cronjob.yaml` legt jede Nacht um 03:15 Uhr eine diff --git a/src/app/api/admin/upload-video/route.ts b/src/app/api/admin/upload-video/route.ts index b112a86..a8b0525 100644 --- a/src/app/api/admin/upload-video/route.ts +++ b/src/app/api/admin/upload-video/route.ts @@ -23,6 +23,27 @@ export const runtime = "nodejs"; */ const MAGIC_BYTES_LENGTH = 12; +/** + * Erkennt, dass die Verbindung WAEHREND des Hochladens gekappt wurde — der + * Body kam also nie vollstaendig an. + * + * Das ist kein Fehler der Verarbeitung, sondern eine Aussage ueber die + * Strecke dazwischen: Browser geschlossen, Netz weg, oder eine Zeitgrenze auf + * dem Weg (Reverse-Proxy, oder Node selbst — dessen requestTimeout bricht + * einen Request nach fuenf Minuten ab, was bei einem grossen Video ueber eine + * langsame Leitung durchaus erreichbar ist). + * + * Ohne diese Unterscheidung landete der Fall in der allgemeinen Auffanglinie + * und wurde als "unerwarteter Fehler" geloggt. Genau das verdeckte die + * eigentliche Aussage: nicht die Verarbeitung ist gescheitert, die Datei ist + * gar nicht erst vollstaendig angekommen. + */ +function isClientAbort(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const code = "code" in error ? error.code : undefined; + return code === "ECONNRESET" || code === "ECONNABORTED" || error.message === "aborted"; +} + /** Signalisiert, dass der Datenstrom MAX_UPLOAD_VIDEO_BYTES ueberschritten hat. */ class UploadTooLargeError extends Error { constructor() { @@ -164,6 +185,25 @@ export async function POST(request: Request) { { status: 413 } ); } + if (isClientAbort(error)) { + // Mit Mengenangabe loggen: daran laesst sich unterscheiden, ob es + // jedes Mal an derselben Stelle abbricht (dann eine Groessengrenze + // auf dem Weg) oder nach ungefaehr derselben ZEIT (dann eine + // Zeitgrenze). Ohne diese Zahlen ist beides nicht auseinanderzuhalten. + console.warn( + `[upload-video] Verbindung abgebrochen nach ${receivedBytes} von angekündigten ${declaredLength || "?"} Bytes.` + ); + // Die Antwort erreicht die Kundschaft meist nicht mehr, die + // Verbindung ist ja weg — sie ist fuer den Fall gedacht, dass nur + // eine Zwischenstation aufgegeben hat und der Browser noch zuhoert. + return NextResponse.json( + { + error: + "Die Verbindung wurde während des Uploads unterbrochen. Bitte erneut versuchen — bei großen Videos hilft es, sie vorher zu verkleinern.", + }, + { status: 408 } + ); + } throw error; } @@ -234,6 +274,16 @@ export async function POST(request: Request) { // (z.B. ein Abbruch beim Schreiben der temporaeren Datei), muss trotzdem // als JSON herauskommen — eine HTML-Fehlerseite waere fuer den Client // nicht auswertbar. + // Ein Abbruch der Verbindung kann auch spaeter noch durchschlagen (etwa + // beim Schliessen des Streams). Auch hier als das benennen, was er ist, + // statt ihn als Fehler der Verarbeitung zu melden. + if (isClientAbort(error)) { + console.warn("[upload-video] Verbindung abgebrochen, bevor der Upload abgeschlossen war."); + return NextResponse.json( + { error: "Die Verbindung wurde während des Uploads unterbrochen. Bitte erneut versuchen." }, + { status: 408 } + ); + } console.error("Video-Upload: unerwarteter Fehler.", error); return NextResponse.json( { error: "Das Video konnte nicht verarbeitet werden." },