Behebung von Fehlerrichtlinien-Problemen bei Async/Await im Produktionscode von Node.js
Erfahren Sie fünf häufige Fehler bei der Fehlerbehandlung mit async/await in JavaScript und Node.js, die zu stillen Fehlern und Rennbedingungen führen, sowie konkrete Lösungen.
Ein von einem Team im letzten Quartal eingesetztes Bezahlsystem führte dazu, dass Kunden für denselben Auftrag zweimal pro Woche fast einen Monat lang doppelt berechnet wurden, bevor das Problem auftrat. Die Ursache lag nicht in einem unzuverlässigen Zahlungsgateway, sondern in einem try/catch-Block, der eine await-Aufruf umschloss und genau das tat, wofür er konzipiert war – den Fehler zu ignorieren und weiterzumachen – während die Wiederholungslogik ein paar Ebenen höher davon ausging, dass eine automatisch abgeschlossene Promise gleichbedeutend mit Erfolg sei.
Niemand führt absichtlich ein solches Problem ein. Da die async/await-Syntax wie gewöhnlicher synchroner Code aussieht, gehen Entwickler von Natur aus genauso vor. Doch das zugrunde liegende Fehlerbehandlungsmodell wird weiterhin durch die Ablehnung von Promises, den Zeitplan für Mikroatgaben sowie Absageregeln gesteuert, die nicht eindeutig mit der Intuition von try/catch übereinstimmen. Selbst erfahrene Ingenieure geraten dadurch in die Falle, oft in Code, der bereits eine Überprüfung bestanden hat, denn solche Fehler treten nur bei Konkurrenzen oder teilweisen Fehlern auf – genau in den Szenarien, die Unit-Tests in der Regel überspringen.
Im Folgenden sind fünf häufige Fehler aufgeführt, die in produktiven JavaScript- und Node.js 22/24-Codebasen vorkommen, zusammen mit Lösungen, die auch unter echtem Traffic funktionieren.
Fehler 1: Fehler abfangen und still weitermachen
Die falsche Vorgehensweise:
async function getUserProfile(userId) {
try {
const res = await fetch(`/api/users/${userId}`);
return await res.json();
} catch (err) {
console.error('Failed to fetch user', err);
return null;
}
}
async function renderDashboard(userId) {
const profile = await getUserProfile(userId);
// profile.name throws here if fetch failed — but the stack trace
// now points at renderDashboard, not at the network call that actually failed
document.title = `${profile.name}'s Dashboard`;
}
Das Einfangen des Fetch-Vorgangs in einer allgemeinen catch-Anweisung verwandelt einen spezifischen, nachvollziehbaren Fehler (eine 500-Antwort von /api/users/42) in einen vagen Fehler (profile ist null). Erst wenn tatsächlich eine Ausnahme auftaucht – wie zum Beispiel TypeError: Cannot read properties of null – geschieht das weit entfernt von der eigentlichen Ursache, ohne dass irgendetwas darauf hindeutet, dass überhaupt eine Netzwerkanfrage stattgefunden hat. In der Produktion bedeutet diese Lücke den Unterschied zwischen einer schnellen fünfminütigen Behebung und einem zweistündigen Durchforsten der Protokolle.
Korrekte Verwendung:
async function getUserProfile(userId) {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) {
throw new Error(`Failed to fetch user ${userId}: ${res.status}`, {
cause: { status: res.status, userId },
});
}
return res.json();
}
async function renderDashboard(userId) {
try {
const profile = await getUserProfile(userId);
document.title = `${profile.name}'s Dashboard`;
} catch (err) {
console.error('Dashboard render failed', err, err.cause);
showErrorBanner('Could not load your profile. Please retry.');
}
}
Die Funktion, die am wenigsten mit Netzwerkanfragen umgehen kann – also die Abruffunktion – sollte einfach einen Fehler auslösen, wenn etwas schiefgeht. Die Entscheidung darüber, was eigentlich unter „Fehler“ zu verstehen ist – ob es darum geht, ein Banner anzuzeigen, die Anfrage erneut zu versuchen oder auf gespeicherte Daten zurückzugreifen – liegt bei der Funktion, die über eine Wiederherstellungsstrategie verfügt. Die Verwendung von Error.cause (eingeführt in ES2022 und verfügbar in allen aktuellen Browsern sowie in Node.js 16.9 und neueren Versionen) bewahrt den strukturierten Kontext, anstatt ihn in eine undurchsichtige Zeichenkettenmeldung zusammenzufassen.
Fehler 2: Verwendung von Promise.all, obwohl Promise.allSettled benötigt wird
Die falsche Vorgehensweise:
async function loadDashboardData(userId) {
const [profile, orders, recommendations] = await Promise.all([
fetchProfile(userId),
fetchOrders(userId),
fetchRecommendations(userId), // a third-party service with a 2% error rate
]);
return { profile, orders, recommendations };
}
Promise.all ist absichtlich schnell fehlgeschlagen: Sobald eine der Promises abgelehnt wird, wird die gesamte Aufrufkette abgelehnt, wodurch die Ergebnisse der anderen Aufrufe ignoriert werden – selbst wenn diese bereits erfolgreich abgeschlossen wurden. Wenn also fetchRecommendations ausläuft, verliert der Benutzer auch den Zugriff auf sein Profil und seine Bestellhistorie, obwohl beide Anfragen tatsächlich fehlerfrei abgeschlossen wurden. Dieses Muster steht hinter einem großen Teil der Beschwerden über „unzuverlässige Dashboards“ bei Code, der ansonsten völlig einwandfrei ist.
Korrekte Verwendung:
async function loadDashboardData(userId) {
const results = await Promise.allSettled([
fetchProfile(userId),
fetchOrders(userId),
fetchRecommendations(userId),
]);
const [profile, orders, recommendations] = results.map((r) =>
r.status === 'fulfilled' ? r.value : null
);
results.forEach((r, i) => {
if (r.status === 'rejected') {
logNonFatal(['profile', 'orders', 'recommendations'][i], r.reason);
}
});
return { profile, orders, recommendations };
}
Eine nützliche Faustregel: Verwenden Sie Promise.all nur dann, wenn jede Operation tatsächlich erforderlich ist und ein teilweises Ergebnis sinnlos wäre – wie beispielsweise drei Schreibvorgänge, die eine einzige atomare Transaktion bilden. Wählen Sie hingegen Promise.allSettled, wenn die Operationen voneinander unabhängig sind und eine teilweise funktionsfähige Benutzeroberfläche besser ist als keine überhaupt – was in der Praxis die meisten Dashboards, Datenaggregationsendpunkte sowie Batch-Verarbeitungsaufgaben umfasst.
Fehler 3: Asynchrone Aufgaben starten und deren Fehler nie behandeln
Das problematische Muster:
function handleClick(event) {
logAnalyticsEvent(event); // returns a promise, nobody awaits it
updateUI();
}
Hier wird logAnalyticsEvent als async deklariert, was bedeutet, dass es unabhängig davon eine Promise zurückgibt, ob jemand darauf reagiert oder nicht. Da niemand einen .catch-Block hinzufügt, hat die Ablehnung dieser Promise keinen Ort, wohin sie gehen kann. Wenn der Analytics-Dienst zufällig nicht erreichbar ist, wird diese Ablehnung zu einer unverarbeiteten Promise-Ablehnung – die von einigen Browsern stillschweigend ignoriert wird, aber in Node.js den Prozess vollständig abstürzen lässt, da unverarbeitete Ablehnungen seit Node.js 15 standardmäßig den Prozess beenden. In einem Request-Handler bedeutet das, dass jeder Benutzer, der auf diese Route zugreift, einen 500-Fehler erhält – und das alles nur wegen eines Hintergrundaufrufs, auf den ohnehin niemand gewartet hat.
Eine sicherere Version:
function handleClick(event) {
void logAnalyticsEvent(event).catch((err) => {
logNonFatal('analytics', err);
});
updateUI();
}
Durch das Vorfügen von void vor den Aufruf machen Sie sowohl zukünftige Wartungsteams als auch Tools wie @typescript-eslint/no-floating-promises darauf aufmerksam, dass das Überspringen von await hier beabsichtigt ist und kein Versehen darstellt. Der .catch-Block übernimmt jedoch die eigentliche Aufgabe, zu verhindern, dass ein Hintergrundfehler zu einem offensichtlichen Ausfall wird. Wenn Ihr Code unter Node.js läuft, lohnt es sich außerdem, als letzte Verteidigungslinie einen obersten process.on('unhandledRejection', ...)-Listener hinzuzufügen – betrachten Sie ihn jedoch als Sicherheitsnetz und nicht als Hauptstrategie. Seine Aufgabe besteht darin, das Problem zu protokollieren und jemanden zu alarmieren, nicht darin, einen vergessenen .catch-Block auszugleichen.
Fehler 4: Asynchrone Aufrufe unkontrolliert laufen lassen, ohne die verlorenen zu stornieren
Das problematische Muster:
async function search(query) {
const results = await fetch(`/api/search?q=${query}`).then((r) => r.json());
renderResults(results);
}
searchInput.addEventListener('input', (e) => search(e.target.value));
Jeder Tastendruck löst eine neue Netzwerkanfrage aus. Da keine Garantie dafür besteht, dass die Antworten in der Reihenfolge ihrer Sendung eintreffen, können veraltete Ergebnisse, falls die Abfrage nach "reac" zufällig nach der Abfrage nach "react" abgeschlossen wird, die korrekten Ergebnisse auf dem Bildschirm überschreiben. Das ist kein seltener Sonderfall – bei einer langsamen oder eingeschränkten Verbindung tritt das ständig auf, und es ist eine der häufigsten Ursachen für die Fehlermeldungen „Das Suchfeld zeigt falsche Ergebnisse“ in jeder Anwendung mit einer Echtzeit-Suchfunktion.
Eine sicherere Version:
let activeController = null;
async function search(query) {
activeController?.abort();
activeController = new AbortController();
const { signal } = activeController;
try {
const res = await fetch(`/api/search?q=${query}`, { signal });
const results = await res.json();
renderResults(results);
} catch (err) {
if (err.name !== 'AbortError') {
logNonFatal('search', err);
}
}
}
searchInput.addEventListener('input', (e) => search(e.target.value));
AbortController, der seit Version 15 von Node.js nativ verfügbar ist und von jeder modernen fetch-Implementierung unterstützt wird, wandelt ein implizites Rennen in ein explizites, kontrolliertes um: Die neueste Anfrage gewinnt, weil jede frühere aktiv abgebrochen wird – und nicht, weil sie zufällig im Zeitwettbewerb gewinnt. Dasselbe Prinzip gilt, wenn ein Komponente während einer Anfrage in React, Angular oder Vue deinstalliert wird – die Anfrage sollte während der Aufräumarbeiten storniert werden, anstatt einfach zu hoffen, dass ihre Antwort niemals ankommt.
Fehler 5: Verlass auf finally anstelle einer ordnungsgemäßen Fehlerbehandlung
Das problematische Muster:
async function processOrder(order) {
let lock;
try {
lock = await acquireLock(order.id);
await chargeCard(order);
await updateInventory(order);
} finally {
releaseLock(lock);
}
}
Zuerst scheint dies sicher zu sein, da finally immer ausgeführt wird und den Lock stets freigeben sollte. Wenn jedoch acquireLock selbst bereits vor der Zuweisung einen Fehler auslöst, bleibt lock zum Zeitpunkt der Ausführung von finally weiterhin undefined. Der Aufruf von releaseLock(undefined) führt in diesem Fall entweder zu einem zweiten, unabhängigen Fehler, der den ursprünglichen Fehler verdeckt, oder bewirkt nichts – je nach Implementierung des Verriegelungsmechanismus – während ein völlig anderer Lock unbegrenzt gehalten wird. finally verspricht lediglich, dass sein Block ausgeführt wird; es sagt nichts darüber aus, ob die darin enthaltene Aufräumlogik tatsächlich für jeden möglichen Ablaufweg gültig ist.
Eine sicherere Version:
async function processOrder(order) {
const lock = await acquireLock(order.id); // outside try — nothing to release yet
try {
await chargeCard(order);
await updateInventory(order);
} finally {
await releaseLock(lock);
}
}
Nur die Operationen, die davon abhängen, dass tatsächlich ein Schutzmechanismus erworben wurde, sollten innerhalb des try-Blocks liegen, der die Aufräumarbeiten auslöst. Es handelt sich um eine kleine strukturelle Änderung, doch sie trennt die Aufräumarbeiten, die genau dann ausgeführt werden, wenn es notwendig ist, von solchen, die unter Umständen stattfinden könnten, in denen sie den Zustand stattdessen verfälschen anstatt ihn zu beheben.
Zusammenfassung
Async/await beseitigt die Schwierigkeiten bei der Fehlerbehandlung in JavaScript niemals – es versteckt sie lediglich hinter einer Syntax, die wie synchroner Code aussieht. Fünf Gewohnheiten beheben die meisten Probleme in der Produktion, die durch diese Versteckung entstehen: Werfen Sie gut definierte Fehlerarten aus, anstatt sie zu unterdrücken, verwenden Sie Error.cause, um den ursprünglichen Fehler beizubehalten; nutzen Sie Promise.allSettled, wenn die zugrunde liegenden Operationen nicht voneinander abhängen; lassen Sie niemals einen Promise ohne .catch unüberwacht; canceln Sie veraltete asynchrone Aufgaben mithilfe von AbortController, anstatt darauf zu vertrauen, dass sich die Netzwerktiming-Probleme von selbst lösen; und beschränken Sie finally-Blöcke auf die Bereinigung von tatsächlich erworbenem Zustand.
Niemand dieser Ansätze ist ungewöhnlich oder fortgeschritten. Was sie darstellen, ist die Kluft zwischen Asynchroncode, der unter realen Netzwerkbedingungen funktioniert, und Asynchroncode, der nur in einer Demo funktionierte.
Verwandte Artikel
- Erstellung einer fehlerbehandlungsfähigen Lösung für Node.js-Anwendungen — Lernen Sie, wie man Fehler in Node.js klassifiziert, eine benutzerdefinierte Fehlerhierarchie entwirft, die asynchrone Fehlerbehandlung zentralisiert und Stacktraces schützt, um die Zuverlässigkeit in der Produktion zu gewährleisten.
- Häufige Missverständnisse bezüglich async/await, die Produktionsschäden verursachen — Erklärt neun subtile Missverständnisse bezüglich async/await – von Rennbedingungen bis hin zu unverarbeiteten Ablehnungen –, die JavaScript-Anwendungen in der Praxis heimlich zerstören.