Neu veröffentlicht: E-Commerce mit Power Pages, Stripe & Analytics

· Webentwicklung  · 7 minuten Lesezeit

Backend Code Review vor dem Launch mit sechs behobenen Problemen

Vor dem Go-Live habe ich das Backend noch einmal vollständig durchgelesen. Sechs konkrete Probleme gefunden, alle behoben. Hier ist, was ich wo gefunden habe und warum es jeweils wichtig war.

Vor dem Go-Live habe ich das Backend noch einmal vollständig durchgelesen. Sechs konkrete Probleme gefunden, alle behoben. Hier ist, was ich wo gefunden habe und warum es jeweils wichtig war.

Inhalt

Warum ich das Backend nochmal gelesen habe

Der Stack läuft. TypeScript kompiliert. Die Features sind implementiert. Aber zwischen “läuft lokal” und “geht in Produktion” liegt eine Lücke: Code, der unter Last, mit echten Nutzerdaten und ohne direkten Debugging-Zugriff funktionieren muss.

Ich habe mir drei Stunden genommen, die Backends-Dateien systematisch durchzulesen, ohne etwas zu ändern. Das Ziel war nicht, Features zu bauen, sondern Stellen zu finden, die in Produktion Probleme verursachen würden.

Ich habe sechs konkrete Probleme gefunden. Alle sind behoben.

Problem 1 mit S3Client als Wegwerfinstanz

In blob-storage.ts gab es Funktionen wie uploadScreenshot, getPresignedUrl und deleteObject. Jede Funktion begann mit:

// S3 client was re-created on every function call
const client = new S3Client({
  endpoint: process.env.S3_ENDPOINT,
  region: process.env.AWS_REGION ?? "auto",
  credentials: { ... },
});

Das bedeutet: Jeder API-Call zum Backend, der S3 berührt, erstellte eine neue S3Client-Instanz. Der Client baut intern einen HTTP-Connection-Pool auf. Dieser Pool wurde nach jedem Aufruf weggeworfen.

Unter Last bedeutet das: viele TCP-Verbindungen, die aufgebaut und sofort wieder geschlossen werden, statt eine bestehende Verbindung wiederzuverwenden. Und wenn die Umgebungsvariablen fehlen, wirft der Konstruktor beim ersten Request, nicht beim Start.

Die Lösung ist ein Lazy-Singleton:

// Module-level variables, initialized on first use
let _client: S3Client | null = null;
let _presignClient: S3Client | null = null;

function getClient(): S3Client {
  if (!_client) {
    _client = new S3Client({ /* config */ });
  }
  return _client;
}

Die Instanz wird beim ersten Aufruf einmalig erstellt. Alle weiteren Aufrufe bekommen dieselbe Instanz. Die Verbindungen im Pool bleiben offen.

Problem 2 mit VECTOR_SIZE als hardcodierte Konstante

In qdrant.ts stand:

// Hardcoded — env var VECTOR_SIZE had no effect
const VECTOR_SIZE = 768;

In docker-compose.yml war gleichzeitig VECTOR_SIZE=768 als Umgebungsvariable gesetzt. Das war eine stille Inkonsistenz: Wer den Wert in der Umgebungsvariable änderte, würde nichts merken, bis Qdrant beim Upsert mit einem Dimensionsfehler antwortet.

Wenn ein Embedding-Modell 1536 Dimensionen liefert (wie text-embedding-3-small von OpenAI), aber die Collection mit 768 angelegt wurde, schlägt jeder Speicherversuch still fehl oder wirft einen unverständlichen Fehler.

// Read from environment with safe fallback
const VECTOR_SIZE = parseInt(process.env.VECTOR_SIZE ?? "768", 10);

Jetzt kontrolliert die Umgebungsvariable tatsächlich den Wert.

Problem 3 mit Admin-PAT bei jedem Request neu einlesen

In account.ts gibt es einen DELETE /account-Endpoint für DSGVO Art. 17 (Recht auf Löschung). Er benötigt einen Zitadel-Admin-PAT, um die Identität des Nutzers in Zitadel zu löschen.

Der ursprüngliche Code rief bei jedem Request eine Funktion auf, die die PAT-Datei vom Filesystem liest:

// Original: file system read on every DELETE request
router.delete("/", authMiddleware, async (req, res) => {
  const pat = await getAdminPat(); // reads file each time
  // ...
});

Bei wenigen Anfragen pro Tag ist das irrelevant. Bei hundert gleichzeitigen Anfragen liest das Backend hundertmal dieselbe Datei. Das Filesystem ist schnell, aber es ist unnötige Arbeit und signalisiert, dass niemand über den Lebenszyklus des Werts nachgedacht hat.

Der PAT ändert sich nicht zur Laufzeit. Er gehört einmalig beim Start geladen:

// Cached at module load, not per request
let _adminPat: string | null = null;

async function loadAdminPat(): Promise<string> {
  if (_adminPat) return _adminPat;
  const patPath = process.env.ZITADEL_ADMIN_PAT_PATH ?? "/run/secrets/zitadel_admin_pat";
  _adminPat = (await fs.readFile(patPath, "utf-8")).trim();
  return _adminPat;
}

Der erste Call beim Modulstart liest die Datei, jeder weitere gibt den gecachten Wert zurück.

Problem 4 mit totem Code im Ternary-Operator

Zwei Stellen im Code hatten einen Ternary-Operator, bei dem eine Bedingung nie eintreten konnte.

In account.ts beim Export-Endpoint:

// Dead branch: items was always an array at this point
const records = items ? items.map(...) : [];

items war zu diesem Zeitpunkt immer ein Array, nie null oder undefined. Der else-Zweig war toter Code.

In query.ts beim Aufbau des LLM-Kontexts:

// Dead branch: platform was narrowed to "generic" at this point
const context = platform === "instagram"
  ? buildInstagramContext(metadata)
  : buildGenericContext(metadata); // always this branch

An dieser Stelle im Code war platform durch TypeScript bereits auf "generic" eingeschränkt. Der Instagram-Zweig konnte nie erreicht werden.

Toter Code in produktionsnahem Code ist ein Warnsignal. Er täuscht vor, dass etwas passieren kann, was nie passiert. Er erschwert das Lesen und kann spätere Refactorings in die falsche Richtung lenken. Beide Stellen wurden vereinfacht.

Problem 5 mit as any und falschem Label

In query.ts gab es einen Block, der für generische Webseiten (also nicht Instagram) einen Kontext für das LLM aufbaute:

// Wrong: cast to any and labelled as Instagram
const meta = metadata as any;
const contextString = `Originally posted on Instagram at ${meta.timestampISO}`;

Zwei Probleme: Erstens, GenericWebPagePayload.metadata hat kein Feld timestampISO. Der Cast auf any versteckte diesen Typfehler. Zweitens, der Text sagte “Originally posted on Instagram” für jede beliebige Webseite.

Das war ein Fall, wo der Code aus dem Instagram-Pfad kopiert und angepasst wurde, aber die Anpassung unvollständig war. Das LLM hätte für jeden gespeicherten Link eine falsche Quelle angezeigt.

// Typed access, no cast, correct label
const meta = metadata as GenericWebPagePayload["metadata"];
const contextString = [
  `Page title: ${meta.pageTitle ?? "unknown"}`,
  `Page URL: ${meta.pageUrl ?? "unknown"}`,
].join("\n");

Kein as any, keine falsche Beschriftung, korrekte Felder aus dem Typ.

Problem 6 mit Import über einen Shim

In query.ts stand:

import { generateEmbedding } from "../services/ollama";

ollama.ts war eine Datei, die nur re-exportierte:

// ollama.ts — misleading backward-compat shim
export { generateEmbedding } from "./ai-provider";

Das war historisch gewachsen: Die Funktion war ursprünglich in ollama.ts, dann in die abstraktere ai-provider.ts verschoben worden, und ollama.ts blieb als Shim. Der Import in query.ts suggerierte, dass die Funktion direkt mit Ollama zusammenhing, was seit dem Wechsel auf ai-provider.ts nicht mehr stimmte.

Der Shim wurde entfernt. query.ts importiert direkt aus ai-provider.ts. Der Code beschreibt jetzt korrekt, was er tatsächlich nutzt.

Was schon gut war

Nicht alles brauchte Arbeit. Einige Teile des Backends waren schon in gutem Zustand:

Das Event-Driven-Muster für den Ingest-Prozess über BullMQ ist sauber. Jobs werden enqueued und von einem Worker abgearbeitet. Fehler enden im failed-Status, nicht in einem stillen Datenverlust.

Die Qdrant-Abstraktion in qdrant.ts isoliert alle Vektordatenbank-Operationen hinter klaren Funktionen. Der Rest des Codes weiß nichts von Qdrant-internen Konzepten.

Der AI-Provider-Abstraktions-Layer (ai-provider.ts) trennt sauber zwischen Ollama lokal und OpenAI in Produktion. Das Switching passiert über eine Umgebungsvariable, nicht über verzweigten Code im Query-Handler.

Was noch offen ist

Zwei Probleme habe ich dokumentiert, aber noch nicht behoben:

Rate Limiting auf POST /query fehlt. Jeder Request triggert Embedding und LLM-Call. Ein einzelnes Skript kann den API-Key leerlaufen lassen.

Der DELETE /account-Endpoint läuft sequenziell durch BullMQ, Qdrant, S3 und Zitadel. Wenn ein Schritt fehlschlägt, bleiben Daten in den anderen Stores erhalten. Das ist ein DSGVO Art. 17-Risiko und braucht einen idempotenten Wiederholungspfad.

Beide sind in PRODUCTION_STRATEGY.md als Blocker vor dem Go-Live dokumentiert.

Übersicht der sechs Backend-Probleme und ihrer Lösungen

Das Diagramm ordnet die sechs Probleme nach Kategorie: Ressourcenverwaltung (S3Client-Singleton, Admin-PAT-Caching), Konfiguration (VECTOR_SIZE), Typkorrektheit (as any, falsches Label) und Codepflege (toter Ternary, Shim-Import). Alle sechs sind behoben. Die zwei offenen Punkte (Rate Limiting, DELETE-Atomizitaet) sind als Go-Live-Blocker markiert.


Alle Artikel der Serie

  1. Vision und Systemübersicht: Chrome Extension, RAG-Architektur, Projekthintergrund: Artikel lesen
  2. RAG-System Aufbau: Qdrant, Embeddings, Cosine-Ähnlichkeit in TypeScript: Artikel lesen
  3. AI Provider Abstraktion: Ollama vs. OpenAI, Interface-Design, kein Vendor-Lock-in: Artikel lesen
  4. Chrome Extension MV3: Drei isolierte Laufzeitkontexte, Message Passing, Strategy Pattern: Artikel lesen
  5. Docker Compose Strategie: Override-Pattern, von lokal zu Azure: Artikel lesen
  6. Ollama lokal vs. Docker: Die Entscheidung und ihre Konsequenzen: Artikel lesen
  7. Ollama Auto-Pull Entrypoint: Automatisiertes Modell-Setup beim Container-Start: Artikel lesen
  8. tsconfig und Vite: Node16 vs. bundler, warum Vite eigene Regeln hat: Artikel lesen
  9. Instagram Caption mit MutationObserver vollständig laden: Artikel lesen
  10. Chrome Extension Foundation mit Health-Dot und Retry-Queue: Artikel lesen
  11. Phase 2 Features: Shadow DOM Overlay, Tailwind v4, Duplicate Detection: Artikel lesen
  12. Race Condition bei der Plattformerkennung: Wie ein UI-Event die Instagram-Erkennung bricht: Artikel lesen
  13. PostId-Extraktion in zwei Instagram-Layouts: querySelector vs. Ancestor-Traversal: Artikel lesen
  14. Instagram Karussell vollständig erfassen mit MutationObserver: Lazy-Loading, Observer-before-click, Timeout-Fallback: Artikel lesen
  15. Notiz und Tags beim Screenshot-Speichern: Artikel lesen
  16. Instagram Tastatur-Shortcuts blockieren Chrome Extension Eingaben: Artikel lesen
  17. Lowercase-Normalisierung und Duplikat-Erkennung im Tag-Input: Artikel lesen
  18. Zitadel Login V2 in Docker Compose: drei versteckte Fehler: Artikel lesen
  19. PKCE OAuth in einer Chrome MV3 Extension: Artikel lesen
  20. React Frontend mit react-oidc-context und Zitadel: Artikel lesen
  21. Vite Build-Time-Umgebungsvariablen in Docker: Artikel lesen
  22. Event-Driven Ingestion mit BullMQ und Redis: Artikel lesen
  23. MinIO statt Azurite: S3-kompatible Objektspeicherung lokal und auf Hetzner: Artikel lesen
  24. access_token, id_token und der Userinfo-Endpoint: was wohin gehört: Artikel lesen
  25. Qdrant Multi-Tenancy: Pro Nutzer eine eigene Collection: Artikel lesen
  26. Wenn Backend und Frontend unterschiedliche Typen kennen: Artikel lesen
  27. Zitadel Bootstrap entfernt: Host-Header-Bug und manuelles Setup: Artikel lesen
  28. Backend Code Review: sechs Probleme vor dem Launch behoben (dieser Artikel)
  29. Traefik statt NGINX: Reverse Proxy für einen wachsenden Docker-Compose-Stack: Artikel lesen
  30. Zweischichtiges Rate Limiting: Traefik und express-rate-limit mit Redis: Artikel lesen
  31. DSGVO Art. 17 korrekt implementieren: Promise.allSettled und Export-Batching: Artikel lesen
  32. Embedding-Modell-Lock-in: Warum mxbai-embed-large eine Produktionsentscheidung für immer ist: Artikel lesen
  33. Docker Volumes in Produktion: Named Volumes, Bind Mounts und der Hetzner-Volume-Trick: Artikel lesen
  34. Zwei Sicherheitslücken vor dem Launch: Redis ohne Auth und ein offener Qdrant-Admin-Port: Artikel lesen

Du willst dein Backend vor dem Go-Live auf ähnliche Schwachstellen prüfen? Lass uns das gemeinsam einschätzen.

Zurück zum Blog

Ähnliche Beiträge

Alle Beiträge ansehen