~/tools/paper-revision zu bauen war der einfache Teil. Es dazu zu bringen, sich in
Produktion genauso zu verhalten wie auf localhost, hat deutlich länger gedauert — und die
meisten Bugs steckten gar nicht im KI-Teil, sondern in der Verkabelung drumherum. Hier ist,
was tatsächlich kaputtging.
Der stille 502: SSE-Verbindungen brauchen einen Heartbeat
Lange Reviews mit einem eigenen (BYOK) Anthropic-Key schlugen gelegentlich nach ~100 Sekunden fehl — keine Fehlermeldung, einfach eine tote Verbindung. Vercels Logs zeigten einen sauberen 502, ohne jeden Hinweis auf die eigentliche Ursache.
Das eigentliche Problem: Ein Modell kann mitten in der Generierung für eine Weile still werden (eine lange interne „Denkphase", bevor der erste sichtbare Token erscheint), und wenn der Antwort-Stream zu lange keine Bytes schickt, entscheidet irgendein Zwischenglied zwischen Client und Modell, dass die Verbindung im Leerlauf ist, und kappt sie. Die Lösung war ein Heartbeat — ein unsichtbares Zeichen, das in festen Intervallen in den Stream geschrieben wird, damit der Bytestrom nie abreißt:
const HEARTBEAT_MS = 15_000
const HEARTBEAT_CHAR = '' // Zero-Width Space, im gerenderten Markdown unsichtbar
const result = await Promise.race([pending, heartbeat])
if (result === 'heartbeat') {
controller.enqueue(encoder.encode(HEARTBEAT_CHAR))
continue
}Bei der Suche danach kam auch ein noch fieserer Bug zum Vorschein, der sich dahinter
versteckte: Der Abort-Handler fing jede Exception namens AbortError ab und behandelte
sie als bewussten Abbruch durch den Nutzer — auch solche, die schlicht daher kamen, dass
das Netzwerk von selbst gestorben war. Echte Fehler wurden dabei stillschweigend
verschluckt. Der Fix bestand darin, zunächst festzuhalten, ob der Nutzer tatsächlich auf
„abort" geklickt hatte, bevor man sich auf den Namen des Fehlers verließ.
BYOK, ohne sich selbst ins Knie zu schießen
Der kostenlose Tarif läuft auf einem gehosteten Modell mit Rate-Limit; BYOK-Nutzer zahlen mit ihrem eigenen Key und umgehen das Limit komplett — nur tat die erste Version genau das nicht, und BYOK-Anfragen fraßen sich ebenfalls durchs Free-Tier-Kontingent. Ans Licht kam das durch einen ehrlichen Bugreport („Aber ich habe doch meinen eigenen Key benutzt und wurde trotzdem gedrosselt").
Die andere BYOK-Lektion war eher proaktiv als reaktiv: die Modell-Allowlist auf vernünftige Preisstufen begrenzen, damit eine Anfrage nicht versehentlich beim teuersten verfügbaren Modell landet, und die Output-Token-Zahl deckeln, unabhängig vom Provider. Nichts davon hindert einen Nutzer daran, sein eigenes Geld schlecht auszugeben, aber es verhindert, dass unser Code der Grund dafür ist.
Ein Favicon, das Google Search partout nicht anzeigen wollte
Das Favicon der Seite wurde in jedem Browser-Tab einwandfrei angezeigt, tauchte aber in den
Google-Suchergebnissen neben der URL einfach nie auf. Die Ursache war fast schon peinlich
indirekt: Next.js' generische Icon-Konvention hängt einen Content-Hash an die URL der Datei
an — /icon.png?d72260d... — und dieser Hash ändert sich jedes Mal, wenn sich die Bytes
der Datei ändern. Googles eigene Richtlinien sind bei genau diesem Fehlerbild ganz
explizit: „Die Favicon-URL muss stabil bleiben — ändere die URL nicht häufig." Jede
Bearbeitung des Icons setzte still und leise das Vertrauen zurück, das Google in die alte
URL aufgebaut hatte.
Next.js macht für genau einen Dateinamen eine Ausnahme — favicon.ico überspringt den
Content-Hash komplett und bekommt eine dauerhaft stabile URL. Dieselbe Grafik in einen
minimalen ICO-Container zu packen und auf diesen Dateinamen umzustellen war der gesamte
Fix. Es lohnt sich, dabei gleich auch das Domain-Setup zu prüfen: Google behandelt
unterschiedliche Hostnamen als komplett getrennte „Sites" im Sinne des Favicons, weshalb
sowohl www. als auch die Standarddomain *.vercel.app dauerhafte Redirects auf den einen
kanonischen Hostnamen bekamen.
Cloudflare Turnstile rendert nur einmal
Turnstiles impliziter Rendering-Modus scannt die Seite genau einmal nach
.cf-turnstile-Elementen, und zwar wenn sein Script zum ersten Mal lädt. Auf einer
klassischen Multi-Page-Site fällt das nicht auf, weil jede Navigation das Script frisch neu
lädt — aber hier handelt es sich um eine SPA, und der Zustand des Review-Formulars überlebt
bewusst die clientseitige Navigation, damit ein laufender Review nicht abgebrochen wird.
Navigiert man weg und wieder zurück, ist der Mount-Point des Widgets ein brandneuer
DOM-Knoten, den das bereits geladene Script nie erneut scannt. Der Token wird still und
leise zu undefined — was, nachdem eine serverseitige Verifizierung für den
Sharing-Endpunkt hinzugekommen war, bedeutete, dass jeder Share-Versuch nach einem Weg- und
Zurücknavigieren ohne sichtbare Erklärung fehlschlug.
Die Lösung ist Turnstiles explizite Rendering-API: Das Script einmal laden und dann bei
jedem Mount selbst turnstile.render() aufrufen sowie bei jedem Unmount
turnstile.remove(), statt sich auf den einmaligen automatischen Scan zu verlassen.
Windows-Entwicklung, Linux-Produktion
Zwei verschiedene Bugs, eine gemeinsame Ursache: Entwicklung unter Windows und Deployment
auf Vercels Linux-Runtime sind nicht dieselbe Umgebung, und PDF-/Bildbibliotheken sind
genau die Stelle, an der diese Kluft sichtbar wird. pdf-parse stürzte in Produktion mit
einem DOMMatrix-Fehler ab, der sich lokal nie reproduzieren ließ — der Wechsel zu einer
serverless-tauglichen PDF-Bibliothek (unpdf) behob das Problem restlos. Unabhängig davon
stürzte die Bildgenerierung von next/og beim Laden von Schriftarten ab, zurückverfolgt
auf einen Windows-Dateipfad mit Leerzeichen, an denen die URL-Parsing-Logik der Bibliothek
erstickte. Für das lokale npm run dev existierte keiner der beiden Bugs; auf jedem
einzelnen Deploy waren beide zu 100 % reproduzierbar.
Was eine Security-Review wirklich aufdeckt
Ein vollständiger Durchgang durch die Codebase förderte eine Handvoll echter, wenn auch unauffälliger Probleme zutage: einen Snapshot-Sharing-Endpunkt ganz ohne Bot-Verifizierung — technisch gesehen konnte damit jeder beliebigen Text auf einer öffentlichen URL veröffentlichen; ein Rate-Limit, das belastet wurde, bevor überhaupt geprüft wurde, ob sich die hochgeladene Datei parsen ließ, sodass ein fehlerhafter Upload das Kontingent eines Nutzers für einen Review verbrannte, der nie stattfinden würde; einen Zip-Handler, der einen Archiveintrag dekomprimierte, bevor er dessen deklarierte Größe prüfte — genau die Form einer Zip-Bomb-Schwachstelle; und einen Share-Button, der beim erneuten Klicken nach einem Weg- und Zurücknavigieren still und leise einen doppelten Snapshot erzeugte, weil der Zustand „haben wir schon einen Link" in einer Komponente lag, die gerade eben verworfen und neu erstellt worden war.
Keines davon war exotisch. Es waren genau die Art von Bug, die nur auftaucht, wenn jemand den Code tatsächlich mit dem Blick darauf liest, was schiefgehen könnte, statt nur zu prüfen, ob der Happy Path funktioniert.
Schlussgedanke
Jeder Bug auf dieser Liste wurde geshippt, funktionierte und wirkte fertig — bis
ausgerechnet ein bestimmter Input, eine bestimmte Navigation oder eine bestimmte
Hosting-Umgebung das Gegenteil bewies. Langweilige Infrastruktur, sorgfältig geprüft,
schlägt clevere Infrastruktur, die nie in der Produktion erprobt wurde. exit 0.