mitmario.dev

Synthese: der Server, der nicht umfällt

Node.js Sandbox 4 Min Lesezeit 3 BeispieleLektion 6 von 7

Die vier Lektionen dieses Abschnitts sind vier Schichten, und sie greifen ineinander. Fehlerklassen allein bringen wenig, wenn niemand sie in Statuscodes übersetzt. Ein Fehler-Handler allein bringt wenig, wenn das Protokoll nicht sagt, zu welcher Anfrage der Eintrag gehört. Und alles zusammen bringt wenig, wenn der Prozess bei der ersten unbehandelten Ablehnung mitten im Satz stirbt.

Die Reihenfolge ist die halbe Miete

Die vier Schichten in Reihenfolge
import express from "express";

process.env.NODE_ENV = "test";

const app = express();

// 1. Ganz oben: alles, was jede Anfrage betrifft.
app.use((req, res, next) => {
  req.anfrageId = "a1b2c3";
  res.setHeader("X-Anfrage-Id", req.anfrageId);
  next();
});

// 2. Die Routen.
app.get("/ok", (req, res) => res.json({ ok: true }));
app.get("/kaputt", (req, res, next) => next(new Error("etwas ging schief")));

// 3. Der Standardfall fuer unbekannte Pfade, drei Argumente.
app.use((req, res, next) => {
  res.status(404).json({ fehler: { code: "nicht_gefunden", anfrageId: req.anfrageId } });
});

// 4. Ganz unten der Fehler-Handler, vier Argumente.
app.use((fehler, req, res, next) => {
  console.log(`ins Protokoll: ${fehler.message} (${req.anfrageId})`);
  res.status(500).json({ fehler: { code: "serverfehler", anfrageId: req.anfrageId } });
});

app.listen(3000);

Rechts steht der gute Fall. /kaputt in der Adresszeile zeigt, was passiert, wenn eine Route wirft: eine kurze Antwort statt eines Stacktraces, und der Server läuft weiter.

Express geht die Middleware von oben nach unten durch, und das legt die Reihenfolge fest:

  1. Ganz oben, was jede Anfrage betrifft. Die Anfragekennung gehört hierhin, weil alles darunter sie braucht: die Kopfzeile in der Antwort, jede Protokollzeile und am Ende der Fehler-Handler.
  2. Dann die Routen. Sie beantworten oder reichen einen Fehler an next() weiter, und sie bauen selbst keine Fehlerantwort.
  3. Dann der Standardfall. Eine Middleware ohne Pfad, die alles einsammelt, was keine Route getroffen hat. Sie hat drei Argumente und macht daraus einen ganz normalen 404 in deiner Form.
  4. Ganz unten der Fehler-Handler, mit vier Argumenten. Das ist die einzige Kennzeichnung, die Express kennt, und ohne das vierte Argument wird er nie aufgerufen.

Am Ende der Ausgabe siehst du, was diese Reihenfolge einbringt: Alle drei Antworten tragen dieselbe Kennung wie die zugehörige Protokollzeile. Wenn dir jemand schreibt „ich bekomme einen Fehler a1b2c3”, findest du die Zeile in einer Sekunde.

Eine Zustandsabfrage, die die Wahrheit sagt

Fast jede Umgebung fragt regelmäßig nach, ob dein Prozess noch gesund ist, und schaltet ihn aus dem Verkehr, wenn nicht. Der übliche Name dafür ist /health.

Eine Zustandsabfrage, die die Wahrheit sagt
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync(":memory:");
db.exec("CREATE TABLE buecher (id INTEGER PRIMARY KEY)");

// Sagt immer ja. Das ist keine Zustandsabfrage, das ist eine Behauptung.
function immerOk() {
  return { status: 200, koerper: { status: "ok" } };
}

// Fragt wirklich nach, und zwar das Billigste, was etwas aussagt.
function ehrlich() {
  try {
    db.prepare("SELECT 1 AS eins").get();
    return { status: 200, koerper: { status: "ok", datenbank: "ok" } };
  } catch (fehler) {
    return { status: 503, koerper: { status: "fehler", datenbank: "weg" } };
  }
}

console.log(`laeuft alles:    immerOk ${JSON.stringify(immerOk())}`);
console.log(`                 ehrlich ${JSON.stringify(ehrlich())}`);

db.close();

console.log(`Datenbank weg:   immerOk ${JSON.stringify(immerOk())}`);
console.log(`                 ehrlich ${JSON.stringify(ehrlich())}`);

Die erste Fassung ist der Normalfall in freier Wildbahn, und sie ist schlimmer als gar keine. Sie beantwortet nämlich nur eine einzige Frage: „Läuft der Node-Prozess noch?” Und wenn er nicht mehr liefe, käme gar keine Antwort, die Frage beantwortet sich also von selbst.

Der Schaden ist echt. Eine Umgebung, die eine Zustandsabfrage hat, vertraut ihr. Steht dort 200, schickt sie weiter Anfragen an einen Prozess, der seit einer Stunde bei jeder Datenbankabfrage scheitert, und der Betreiber sieht ein grünes Feld, während die Kunden Fehler bekommen. Ohne die Abfrage hätte er wenigstens nichts geglaubt.

Die zweite Fassung fragt wirklich nach, und zwar mit dem Billigsten, was etwas aussagt: SELECT 1. Klappt es, ist die Verbindung da. Klappt es nicht, ist der ehrliche Statuscode 503.

Zwei Fallen dabei. Erstens: Die Abfrage darf nicht selbst teuer sein, sonst legt die Überwachung den Server lahm. Kein count(*) über eine große Tabelle, kein Aufruf nach draußen bei jeder Prüfung. Zweitens: Sie darf nichts verraten. Versionsnummern, Verbindungszeichenketten und Fehlermeldungen gehören dort genauso wenig hin wie in eine Fehlerantwort, denn /health ist öffentlich erreichbar.

Was in echt noch dazugehört

Zeitgrenze und Wiederholung
async function langsam(ms, ergebnis) {
  await new Promise((fertig) => setTimeout(fertig, ms));
  return ergebnis;
}

/** Eine Zeitgrenze um einen Aufruf, der sonst ewig laufen koennte. */
async function mitZeitgrenze(versprechen, ms) {
  let zeitgeber;
  const grenze = new Promise((_, ablehnen) => {
    zeitgeber = setTimeout(() => ablehnen(new Error(`Zeitgrenze nach ${ms} ms`)), ms);
  });

  try {
    return await Promise.race([versprechen, grenze]);
  } finally {
    clearTimeout(zeitgeber);
  }
}

/** Wiederholen mit wachsendem Abstand, damit ein wunder Dienst Luft bekommt. */
async function mitWiederholung(arbeit, versuche) {
  for (let n = 1; ; n++) {
    try {
      return await arbeit(n);
    } catch (fehler) {
      if (n >= versuche) throw fehler;
      const warten = 2 ** (n - 1) * 50;
      console.log(`Versuch ${n} gescheitert (${fehler.message}), neuer Versuch in ${warten} ms`);
      await new Promise((fertig) => setTimeout(fertig, warten));
    }
  }
}

const schnell = await mitZeitgrenze(langsam(20, "Antwort"), 100);
console.log(`schnell genug: ${schnell}`);

try {
  await mitZeitgrenze(langsam(500, "Antwort"), 100);
} catch (fehler) {
  console.log(`zu langsam:    ${fehler.message}`);
}

const ergebnis = await mitWiederholung(async (n) => {
  if (n < 3) throw new Error("Dienst antwortet nicht");
  return `beim ${n}. Versuch geklappt`;
}, 5);

console.log(`Wiederholung:  ${ergebnis}`);

Zwei Bausteine, die dieser Kurs nicht mehr eigens behandelt, die du aber kennen solltest, sobald dein Server mit jemand anderem redet.

Eine Zeitgrenze um jeden Aufruf nach draußen. Ohne sie erbst du die Geduld des anderen Dienstes: Wenn er zehn Minuten braucht, hängt deine Anfrage zehn Minuten, und mit ihr eine Verbindung, ein Stück Speicher und ein wartender Nutzer. Ein hängender Aufruf ist gefährlicher als ein schnell gescheiterter, weil er sich aufstaut. Beachte auch das finally aus Lektion 16.2: Ohne clearTimeout bliebe der Zeitgeber stehen und hielte den Prozess am Leben.

Wiederholen mit wachsendem Abstand, wenn ein Fehler vorübergehend sein kann. Wachsend deshalb, weil sofortige Wiederholungen genau das tun, was ein überlasteter Dienst gerade nicht braucht. Und nur bei Fehlern, die vorübergehen können: Ein 500 oder eine Zeitüberschreitung lohnt einen zweiten Versuch, ein 400 nicht. Ein POST zu wiederholen legt außerdem womöglich zwei Bestellungen an, und das ist ein anderes Thema.

Was du danach hast

Deine Bücher-API kann nach dieser Lektion vier Dinge, die sie vorher nicht konnte: Sie unterscheidet ihre eigenen Fehler von fremden, sie beantwortet jeden davon in derselben Form, sie sagt ehrlich, wie es ihr geht, und sie hinterlässt bei jedem Vorfall eine Spur, an der man ihn nachvollziehen kann.

Was ihr dann noch fehlt, ist der Nachweis, dass sie das auch morgen noch tut. Das ist Abschnitt 17.

Zum Mitnehmen

Vier Schichten übereinander: Fehler unterscheiden, sie an einer Stelle in Antworten verwandeln, das Unerwartete sauber beenden, und alles davon so protokollieren, dass man es morgen noch nachvollziehen kann.

Jetzt du

Basis Konto, kostenlos

Zu dieser Lektion gehört eine Aufgabe. Du schreibst den Code selbst, und nach jedem Lauf sagt dir eine Prüfliste, was schon stimmt.

Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.

In diesem Kurs läuft dein Code auf einem Server. Dafür hat das Basis Konto 1 Stunde im Monat, mehr Zeit gibt es mit dem Premium Konto.

Was in dieser Lektion steckt

  • Artikel mit 3 Beispielen zum Ausprobieren

    Steht hier, ohne Konto lesbar.

  • Aufgabe, dein Code läuft auf einem Server

    Öffnet sich mit dem Basis Konto.