mitmario.dev

try/catch und async

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

Das hier ist der Fehler, den man am schwersten findet, weil der Code richtig aussieht. Der try steht da. Der catch steht da. Und trotzdem stürzt das Programm ab.

Warum der catch danebensteht

Ein try/catch ist an den Aufrufstapel gebunden, nicht an eine Zeile. Es fängt, was passiert, solange die Ausführung innerhalb des Blocks ist. Ein Aufruf ohne await ist aber sofort fertig: Er startet die Arbeit und gibt ein Promise zurück. Die Funktion läuft weiter, verlässt den try, gibt zurück, und erst danach lehnt das Promise ab. Zu dem Zeitpunkt existiert der catch in diesem Sinne nicht mehr.

Der catch, der nichts fängt
async function kaputt() {
  throw new Error("Datenbank weg");
}

// Ohne await ist die Funktion laengst zurueck, wenn das Promise ablehnt.
async function ohneAwait() {
  try {
    kaputt();
    return "durchgelaufen";
  } catch (fehler) {
    return "gefangen";
  }
}

async function mitAwait() {
  try {
    await kaputt();
    return "durchgelaufen";
  } catch (fehler) {
    return "gefangen";
  }
}

// Nur damit das Beispiel nicht abstuerzt, bevor es etwas gezeigt hat.
process.on("unhandledRejection", (grund) => {
  console.log(`spaeter, ohne Zuhoerer: ${grund.message}`);
});

const ohne = await ohneAwait();
console.log(`ohne await: ${ohne}`);

const mit = await mitAwait();
console.log(`mit await:  ${mit}`);

Zwei Funktionen, die sich in einem Wort unterscheiden. Die erste läuft durch, als sei nichts gewesen, und meldet den Erfolgswert. Die zweite fängt, wie man es erwartet.

Der eigentliche Fehler steht in der dritten Zeile der Ausgabe: Das abgelehnte Promise ist nicht verschwunden, es kommt nur später und findet niemanden. Ohne den unhandledRejection-Zuhörer, den das Beispiel nur zur Vorführung eingebaut hat, würde Node den Prozess an dieser Stelle beenden. Was dahintersteckt, ist Lektion 16.4.

Die Regel dazu ist kurz: Jeder Aufruf einer async-Funktion braucht ein await, ein .catch() oder eine ausdrückliche Entscheidung. Es gibt Fälle, in denen man ein Promise absichtlich losschickt, ohne zu warten. Dann gehört ein .catch() daran, und zwar auf derselben Zeile, damit man beim Lesen sieht, dass es Absicht war.

return gegen return await

Es gibt eine Stelle, an der das scheinbar überflüssige await doch etwas ändert, und zwar genau eine: return innerhalb eines try.

return gegen return await
import { readFile } from "node:fs/promises";

async function mitReturn() {
  try {
    return readFile("gibtsnicht.txt", "utf8");
  } catch (fehler) {
    return "Rückfall";
  }
}

async function mitReturnAwait() {
  try {
    return await readFile("gibtsnicht.txt", "utf8");
  } catch (fehler) {
    return "Rückfall";
  }
}

try {
  console.log(`return:       ${await mitReturn()}`);
} catch (fehler) {
  console.log(`return:       geplatzt beim Aufrufer (${fehler.code})`);
}

console.log(`return await: ${await mitReturnAwait()}`);

return readFile(...) gibt das Promise zurück, und die Funktion ist damit fertig. Der try-Block ist verlassen, bevor die Datei überhaupt gesucht wurde. Wenn das Promise dann ablehnt, platzt es beim Aufrufer, und der catch ein paar Zeilen darüber hat nie etwas davon gemerkt.

return await readFile(...) wartet noch innerhalb des try. Erst der ausgepackte Wert wird zurückgegeben, und ein Fehler landet dort, wo er hingehört.

Überall sonst ist return await tatsächlich überflüssig und darf weg. Innerhalb eines try ist es Pflicht. Manche Linter markieren return await pauschal als unnötig, und genau deshalb steht diese Regel hier: Die Ausnahme kennt der Linter nicht immer.

Was in einen catch gehört

Ein catch, der nur protokolliert und dann weitermacht, als sei nichts gewesen, ist kein Behandeln. Er ist ein Versteck mit Logeintrag. Das Programm läuft mit halben Daten weiter, und im Protokoll steht eine Zeile, die niemand liest, weil ja alles zu funktionieren scheint.

Es gibt genau drei sinnvolle Inhalte, und mindestens einer davon muss drinstehen.

Behandeln, umwandeln, weiterwerfen
class BestellFehler extends Error {
  constructor(meldung, ursache) {
    super(meldung, { cause: ursache });
    this.name = "BestellFehler";
    this.code = "bestellung_kaputt";
  }
}

async function lade() {
  throw new Error("Zeitüberschreitung nach 2000 ms");
}

// 1. Behandeln: es gibt eine Antwort, also gib sie.
async function behandeln() {
  try {
    return await lade();
  } catch (fehler) {
    return "aus dem Zwischenspeicher";
  }
}

// 2. Umwandeln: der Aufrufer bekommt etwas, mit dem er umgehen kann.
async function umwandeln() {
  try {
    return await lade();
  } catch (fehler) {
    throw new BestellFehler("Die Bestellung liess sich nicht laden", fehler);
  }
}

// 3. Weiterwerfen: hier ist nicht der Ort dafuer. Aufraeumen aber schon.
async function weiterwerfen() {
  console.log("weiterwerfen: Verbindung offen");
  try {
    return await lade();
  } finally {
    console.log("weiterwerfen: Verbindung geschlossen");
  }
}

const behandelt = await behandeln();
console.log(`behandeln:    ${behandelt}`);

try {
  await umwandeln();
} catch (fehler) {
  console.log(`umwandeln:    ${fehler.name}, Ursache ${fehler.cause.message}`);
}

try {
  await weiterwerfen();
} catch (fehler) {
  console.log(`weiterwerfen: ${fehler.message} kam beim Aufrufer an`);
}

Behandeln heißt: Für diese Lage gibt es eine Antwort, und die gebe ich jetzt. Ein Rückfall auf den Zwischenspeicher, ein Standardwert, ein leeres Ergebnis. Das ist der Fall aus Lektion 16.1.

Umwandeln heißt: Der Fehler ist echt, aber in dieser Form kann der Aufrufer nichts damit anfangen. Also einen eigenen daraus machen, mit cause den ursprünglichen dranhängen und weiterwerfen. Aus einer Zeitüberschreitung wird ein BestellFehler, und wer den fängt, weiß, was schiefging, ohne die Innereien zu kennen.

Weiterwerfen heißt: Hier ist nicht der Ort dafür. Dann braucht man streng genommen gar keinen catch, sondern nur ein finally. Ein try ohne catch ist völlig in Ordnung und sagt genau das: Ich fange hier nichts, aber aufgeräumt wird auf jeden Fall.

Wofür finally wirklich gut ist

finally läuft immer. Nach dem return im Erfolgsfall, nach dem catch, und auch dann, wenn der Fehler durchfliegt und diese Funktion gar nicht mehr zuständig ist.

Genau dafür ist es da: aufräumen, was auf jeden Fall aufgeräumt gehört. Eine Datei schließen, eine Datenbankverbindung zurückgeben, ein Sperrkennzeichen entfernen, einen Zähler zurücksetzen. Im dritten Fall des Beispiels siehst du das an der Reihenfolge: Die Verbindung wird geschlossen, und danach kommt der Fehler beim Aufrufer an.

Was nicht in ein finally gehört, ist der Erfolgsfall. Wer dort das Ergebnis verarbeitet oder eine Antwort schickt, tut das auch dann, wenn gerade alles schiefgegangen ist.

Zum Mitnehmen

Ein try/catch fängt nur, was passiert, solange es zuständig ist. Ohne await ist die Funktion längst zurück, wenn das Promise ablehnt, und der catch steht daneben und schaut zu.

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.