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." },