mitmario.dev

Der zentrale Fehler-Handler

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

In Lektion 10.5 hast du einen Ort für alle Fehler gebaut, in 11.6 eine Form für alle Antworten. Was fehlt, ist der Schritt dazwischen: Wie wird aus einem Fehler ein Statuscode, ohne dass jede Route ihn selbst setzt?

Die naheliegende Antwort ist die falsche

Der Handler aus 11.6 nimmt fehler.status ?? 500. Das funktioniert wunderbar, solange alle Fehler aus deinem eigenen Code kommen. Nur kommen sie das nicht.

Der Handler, der dem Fehler glaubt
import express from "express";

// Damit Express nicht zusaetzlich den vollen Stacktrace in die Ausgabe legt.
process.env.NODE_ENV = "test";

const app = express();

// Ein Fehler aus einer fremden Bibliothek. Er traegt einen Status und einen
// Code, und beide gehoeren nicht deiner Anwendung.
app.get("/preis", (req, res, next) => {
  const fehler = new Error("Preisdienst antwortete 404 auf /v2/preise");
  fehler.status = 404;
  fehler.code = "preisdienst_404";
  next(fehler);
});

// Der Handler aus Lektion 11.6: er glaubt, was am Fehler steht.
app.use((fehler, req, res, next) => {
  res.status(fehler.status ?? 500).json({ code: fehler.code ?? "serverfehler" });
});

app.listen(3000);

Ein Aufruf nach draußen scheitert, die Bibliothek hängt den Statuscode des fremden Dienstes an ihren Fehler, und dein Handler reicht ihn durch. Ergebnis: Deine API antwortet mit 404, obwohl das angefragte Buch existiert. Der Aufrufer schließt daraus, dass es die Ressource nicht gibt, und löscht sie vielleicht aus seinem Zwischenspeicher.

Der Code ist genauso schlimm. preisdienst_404 verrät den Namen eines internen Dienstes und die Struktur deines Systems, und ein Aufrufer, der darauf reagiert, hängt an einem Detail, das du morgen austauschen willst.

Ein Fehlerobjekt ist eine Eingabe wie jede andere, und status ist ein Feld darauf, das irgendwer gesetzt hat. Aus Lektion 15.1 kennst du die Haltung dazu: Allowlist statt Blocklist. Zähl auf, was du kennst, statt zu hoffen, dass der Rest schon passen wird.

Zwei Fragen, in dieser Reihenfolge

Eine Zuordnung an genau einer Stelle
import express from "express";

process.env.NODE_ENV = "test";

class AppFehler extends Error {
  constructor(meldung, code) {
    super(meldung);
    this.name = new.target.name;
    this.code = code;
  }
}

class NichtGefunden extends AppFehler {
  constructor(was) {
    super(`${was} gibt es nicht`, "nicht_gefunden");
  }
}

// Die Zuordnung, an genau einer Stelle. Was hier nicht steht, ist ein 500.
const STATUS = { nicht_gefunden: 404, ungueltige_eingabe: 400, nicht_angemeldet: 401 };

const app = express();

app.get("/buecher/99", (req, res, next) => next(new NichtGefunden("Buch 99")));

app.get("/preis", (req, res, next) => {
  const fehler = new Error("Preisdienst antwortete 404 auf /v2/preise");
  fehler.status = 404;
  fehler.code = "preisdienst_404";
  next(fehler);
});

app.use((fehler, req, res, next) => {
  const bekannt = fehler instanceof AppFehler && STATUS[fehler.code] !== undefined;
  const status = bekannt ? STATUS[fehler.code] : 500;

  res.status(status).json({
    fehler: {
      code: bekannt ? fehler.code : "serverfehler",
      meldung: bekannt ? fehler.message : "Da ist etwas schiefgegangen",
    },
  });
});

app.listen(3000);

Der Handler stellt zwei Fragen, und die Reihenfolge ist wichtig.

Erstens: Ist das überhaupt einer von meinen? Das beantwortet fehler instanceof AppFehler. Eine gemeinsame Wurzelklasse kostet acht Zeilen und ist genau dafür da: Alle deine Fehlerarten erben von ihr, und damit gibt es eine einzige Prüfung statt einer Liste von Klassen.

Zweitens: Welchen Status hat dieser Code? Das beantwortet die Tabelle STATUS. Sie ist ein gewöhnliches Objekt, sie steht an einer Stelle, und eine neue Fehlerart ist eine Zeile darin.

Fällt eine der beiden Fragen negativ aus, ist die Antwort 500, und zwar mit deinem eigenen Code und deinem eigenen Satz. Nicht mit dem, was am Fehler stand. Der zweite Aufruf im Beispiel zeigt das Ergebnis: derselbe Fehler wie eben, jetzt als sauberer Serverfehler.

Damit gilt eine Regel, die du dir merken kannst: Ein unbekannter Fehler ist immer 500. Nicht, weil 500 besonders schön wäre, sondern weil jeder andere Code eine Aussage über die Anfrage wäre, die du nicht belegen kannst. 500 heißt genau das Richtige: Es liegt an mir, nicht an dir.

Wann ein fremder Status doch stimmt

Es gibt eine Ausnahme, und sie ist keine Aufweichung, sondern eine Entscheidung, die du bewusst triffst.

Wann ein fremder Status doch stimmt
import express from "express";

process.env.NODE_ENV = "test";

const app = express();
app.use(express.json());
app.post("/buecher", (req, res) => res.status(201).json({ ok: true }));

app.get("/", (req, res) =>
  res.type("text/plain").send(
    `curl -i -X POST -H "Content-Type: application/json" -d '{kaputt' http://localhost:3000/buecher\n`
  )
);

app.use((fehler, req, res, next) => {
  // Was express.json() an den Fehler haengt, steht hier in der Antwort.
  res
    .status(fehler.status ?? 500)
    .type("text/plain")
    .send(
      [
        `name:    ${fehler.name}`,
        `status:  ${fehler.status}`,
        `type:    ${fehler.type}`,
        `expose:  ${fehler.expose}`,
      ].join("\n") + "\n"
    );
});

app.listen(3000);

Der Befehl rechts schickt absichtlich kaputtes JSON. In der Antwort steht, was express.json() an den Fehler gehängt hat.

Wenn express.json() einen kaputten Körper bekommt, wirft es einen SyntaxError mit status: 400 und type: "entity.parse.failed". Der Status ist hier richtig: Der Aufrufer hat wirklich Unsinn geschickt, und er kann wirklich etwas dagegen tun. Genau das hat auch die Bücher-API aus 15.7 genutzt, um bei einem zu großen Körper mit 413 zu antworten.

Das dritte Feld ist der Grund, warum das kein Widerspruch ist: expose. Die Bibliothek http-errors, die Express dafür benutzt, setzt es auf true, wenn der Status unter 500 liegt, und meint damit: Diese Meldung darf nach außen. Bei einem 5xx steht dort false.

Wer solche Fehler durchlassen will, prüft also nicht fehler.status, sondern fehler.expose === true && fehler.status < 500. Und auch dann ist es eine bewusste Zeile im Handler und keine Voreinstellung.

Die zwei Gesichter

Zum Schluss der Teil, den Lektion 15.7 schon angerissen hat und der hier seinen festen Platz bekommt: Eine Fehlerantwort und ein Logeintrag sind zwei verschiedene Texte für zwei verschiedene Leser.

Nach außen: Statuscode, dein Code, ein Satz. Bei 4xx darf der Satz konkret sein, denn der Aufrufer soll ja etwas ändern können. Bei 5xx ist er allgemein, weil es ihn nichts angeht und weil jedes Detail eine Auskunft über dein System ist.

Nach innen: alles. Der volle Fehler mit Stacktrace, seine cause-Kette, der Pfad, die Methode und eine Anfragekennung. Und zwar nur bei einem 5xx: Ein 400 ist eine Auskunft an den Aufrufer und kein Vorfall auf deiner Seite. Wer jeden 404 protokolliert, hat nach einer Woche ein Protokoll, in dem man nichts mehr findet.

Genau dieses Muster steckt auch in sanitizeErrorMessage im Repo dieser Seite: Details gibt es nur in der Entwicklungsumgebung, nach außen bleibt ein Satz. Wie es dann nach innen aussehen soll, ist Lektion 16.5.

Zum Mitnehmen

Deine Fehler bekommen den Status, den du ihnen zuordnest. Alle anderen bekommen 500. Ein Statuscode, der aus einem fremden Fehlerobjekt stammt, ist eine Behauptung, die du nicht geprüft hast.

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.