Kontext
Unbehandelte Fehler sind nicht nur Fehler im Code. Sie sind Verhalten der Infrastruktur.
Was in einer Lambda-Funktion nach einer Exception passiert, hängt weniger vom eigenen Code ab als davon, wie die Funktion aufgerufen wurde. Auf der produktiven Plattform, an der ich bei Formilo arbeite, läuft fast alles asynchron: EventBridge-Regeln und -Zeitpläne, SQS-Queues, DynamoDB Streams. Die Frage „Was passiert, wenn das hier einen Fehler wirft?“ hat also je nach Aufrufer eine andere Antwort.
Problem
AWS zählt unbehandelte Exceptions, Timeouts und abgelehnte Promises als Funktionsfehler und behandelt sie je nach Aufrufart unterschiedlich:
- Asynchroner Aufruf (EventBridge, SNS, S3 und andere): Lambda wiederholt die Funktion noch zweimal, mit Backoff. Schlagen alle Versuche fehl, geht das Event in eine Dead-Letter-Queue oder an ein On-Failure-Ziel, sofern eines konfiguriert ist.
- Synchroner Aufruf (API Gateway, ein direkter SDK-Aufruf): Der Fehler geht sofort an den Aufrufer zurück. Lambda wiederholt nicht; ob wiederholt wird, entscheidet der Aufrufer.
- Poll-basierte Quellen (SQS, Streams): Das Event Source Mapping wiederholt. Eine SQS-Nachricht kommt nach dem Visibility Timeout zurück, ein Stream-Batch wird in Reihenfolge wiederholt.
Riskant ist der asynchrone Fall:
- vor dem Fehler kann bereits ein Teil ausgeführt worden sein,
- Retries können doppelte Seiteneffekte auslösen,
- nachgelagerte Systeme können doppelt betroffen sein,
- und der umgekehrte Fehler, eine abgefangene und verschluckte Exception, lässt das Event ganz verschwinden.
Was ich zuerst falsch gemacht habe
Den letzten Punkt habe ich in meinem eigenen Code gefunden. In einem früheren Produkt von 2025, gebaut auf DynamoDB-Stream → Lambda-Mappings:
- ein Handler gab
batchItemFailures: []zurück, aber im Mapping warReportBatchItemFailuresnie aktiviert, der Rückgabewert hatte also keine Wirkung, - ein anderer Handler fing seine Fehler ab und machte weiter, Lambda sah also einen Erfolg, und nichts wurde wiederholt.
Ein vergessenes try/catch lässt AWS wiederholen. Ein falsches try/catch versteckt den Fehler. Beides sind Entscheidungen über das Verhalten der Infrastruktur, ob man sie bewusst trifft oder nicht. Deshalb bin ich mit DynamoDB Streams vorsichtiger geworden.
Was in Produktion hilft
Idempotenz
Handler so entwerfen, dass wiederholte Ausführung sicher ist. Der Scraper prüft zuerst den Task: Ist er bereits Finished oder Failed, bestätigt er die Nachricht und überspringt sie. Der E-Mail-Versand-Worker übernimmt seinen Task mit einem bedingten Schreibzugriff auf Running, bevor er irgendetwas sendet.
Bekannte Fehler ausdrücklich behandeln
Erwartete Fehler abfangen, loggen und behandeln. Der Scraper hat einen FatalScrapeError für Fälle, die ein Retry nicht beheben kann, etwa eine ungültige URL. Er setzt den Task auf Failed und wiederholt nicht. Auch eine nicht lesbare Nachricht scheitert ohne Retry.
Unerwartete Fehler scheitern lassen
Nicht verschlucken, was man nicht versteht. Alles, was kein bekannter fataler Fehler ist, geht zurück an SQS:
const batchItemFailures: { itemIdentifier: string }[] = [];
for (const record of event.Records) {
try {
await processTask(record);
} catch (error) {
if (error instanceof FatalScrapeError) {
await markTaskFailed(record, error); // kein Retry
continue;
}
// Unbekannter Fehler: SQS soll erneut zustellen.
batchItemFailures.push({ itemIdentifier: record.messageId });
}
}
return { batchItemFailures };
Das funktioniert nur mit reportBatchItemFailures: true an der Event Source. Wie der Kommentar in unserem Infrastrukturcode sagt: Ohne diese Option wird batchItemFailures ignoriert, und Nachrichten werden auch bei Fehlern gelöscht. (Der Handler ist vereinfacht.)
Endzustände schützen
Einen Task beim Retry nie rückwärts bewegen. Der E-Mail-Versand-Worker überspringt Tasks, die bereits Finished oder Cancelled sind, und wirft dabei keinen Fehler: Das würde nur dazu führen, dass der Scheduler bis in die DLQ wiederholt.
Fehlgeschlagenen Events einen Ort geben
Fehlgeschlagene Events brauchen ein Ziel. Die Scrape-Queue verschiebt eine Nachricht nach drei Empfängen in ihre DLQ, mit einem Alarm bei der ersten Nachricht; die E-Mail-Zeitpläne erlauben zwei Wiederholungen und haben eine eigene DLQ mit Alarm.
Überwachen und abgleichen
Retries ersetzen keinen Abgleich. Stirbt ein Worker mitten im Task, bleibt eine Zeile in Running zurück, die kein Retry anfasst. Reconciler laufen nach Zeitplan: alle 5 Minuten für Scrape-Tasks, alle 30 Minuten für E-Mail-Tasks. Sie legen neu an, was fehlt, und setzen hängende Tasks auf einen Endzustand.
Abwägungen
- Fehler zu klassifizieren ist Arbeit. Zu entscheiden, was fatal ist und was wiederholbar, heißt Fehlerfälle einzeln durchzugehen. Falsch liegen kostet in beide Richtungen: endlose Retries oder verlorene Arbeit.
- Retries kosten Zeit und Geld. Eine wiederholte Nachricht kommt erst nach dem Visibility Timeout zurück, und jeder Versuch ist ein weiterer Aufruf.
- DLQs brauchen jemanden, der sich kümmert. Ein Alarm nützt nur, wenn jemand darauf reagiert.
Ergebnis
- Bekannte Fehler werden ausdrücklich behandelt; unbekannte scheitern in Retries und am Ende in eine DLQ.
- Doppelte Ausführungen sind harmlos, weil Endzustände geschützt sind.
- Hängende Tasks reparieren Reconciler, statt auf einen Retry zu warten, der nie kommt.
Lambda wird wiederholen. Die Architektur sollte darauf vorbereitet sein.