Systeme für Ausfälle designen (denn sie werden ausfallen)
Ausfallmuster für Produktionssysteme. Circuit Breaker, Retry-Strategien, Graceful Degradation, Dead Letter Handling, Timeout-Budgets und Chaos Engineering für kleine Teams.
Die Fehler-Taxonomie
Systeme versagen auf vier Arten. Jede erfordert eine andere Reaktion.
| Typ | Beschreibung | Beispiel | Korrekte Reaktion |
|---|---|---|---|
| Transient | Kurzer Aussetzer, löst sich von selbst | Netzwerk-Timeout, Verbindungsabbruch | Retry mit Backoff |
| Permanent | Kaputt bis jemand es repariert | Ungültige Konfiguration, Schema-Mismatch | Schnell fehlschlagen, alarmieren, nicht wiederholen |
| Partiell | Einige Funktionen arbeiten noch | Einer von drei Zulieferern ist down | Graceful Degradation, liefere was geht |
| Kaskadierend | Ein Fehler löst weitere aus | Datenbanküberlastung verursacht Timeouts in allen Services | Circuit Breaker, Last abwerfen |
Der gefährlichste Fehler: jeden Ausfall gleich behandeln. Permanente Fehler zu wiederholen verschwendet Ressourcen. Transiente Fehler nicht zu wiederholen verschlechtert die Nutzererfahrung. Kaskadierende Fehler nicht zu erkennen legt das gesamte System lahm.
Wie wir Ausfälle speziell in Event-driven Systemen behandeln, erfährst du in unserem Event-Driven Architecture Guide. Für KI-Systemausfälle schau dir unseren KI-Fehlermodi-Guide an.
Circuit Breaker
Ein Circuit Breaker verhindert, dass ein fehlerhafter Service alles mit sich reißt, was von ihm abhängt. Wenn ein Downstream-Service wiederholt ausfällt, "öffnet" sich der Circuit Breaker und beantwortet Anfragen sofort mit einem Fehler, anstatt auf Timeouts zu warten.
class CircuitBreaker {
private failures = 0;
private lastFailure = 0;
private state: 'closed' | 'open' | 'half-open' = 'closed';
constructor(
private threshold: number = 5, // Fehler bis zur Öffnung
private resetTimeout: number = 30000, // ms bis zum nächsten Versuch
) {}
async execute<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === 'open') {
if (Date.now() - this.lastFailure > this.resetTimeout) {
this.state = 'half-open'; // Einen Request versuchen
} else {
throw new CircuitOpenError('Circuit is open');
}
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private onSuccess() {
this.failures = 0;
this.state = 'closed';
}
private onFailure() {
this.failures++;
this.lastFailure = Date.now();
if (this.failures >= this.threshold) {
this.state = 'open';
}
}
}
// Verwendung
const supplierBreaker = new CircuitBreaker(5, 30000);
async function checkAvailability(productId: string) {
return supplierBreaker.execute(async () => {
return await supplierApi.checkAvailability(productId);
});
}
Der häufige Fehler
Die meisten Teams implementieren den Circuit Breaker, behandeln aber den offenen Zustand nicht. Wenn der Circuit offen ist, was sieht der Nutzer? Ein 500-Fehler ist die falsche Antwort. Die richtige Antwort hängt vom Kontext ab:
| Kontext | Wenn der Circuit öffnet | Korrektes Verhalten |
|---|---|---|
| Produktsuche | Ein Zulieferer ist down | Ergebnisse von anderen Zulieferern zeigen |
| Preisabfrage | Pricing-Service ist down | Gecachten Preis mit "Stand: X" Label anzeigen |
| Checkout | Payment Gateway ist down | Bestellung in Queue stellen, später verarbeiten |
| Empfehlungen | ML-Service ist down | Beliebte Artikel stattdessen anzeigen |
| Bilddienst | CDN ist down | Platzhalter-Bild anzeigen |
Retry-Strategien
Nicht alle Retries sind gleich. Die Strategie hängt vom Fehlertyp ab.
interface RetryConfig {
maxAttempts: number;
strategy: 'immediate' | 'fixed' | 'exponential' | 'none';
baseDelay: number; // ms
maxDelay: number; // ms
jitter: boolean; // Zufallsverteilung um Thundering Herd zu vermeiden
}
const RETRY_CONFIGS: Record<string, RetryConfig> = {
// Transient: Retry mit exponentiellem Backoff
network_timeout: {
maxAttempts: 3,
strategy: 'exponential',
baseDelay: 1000, // 1s, 2s, 4s
maxDelay: 10000,
jitter: true,
},
// Rate Limited: Retry mit festem Delay
rate_limited: {
maxAttempts: 5,
strategy: 'fixed',
baseDelay: 5000, // 5s zwischen den Versuchen
maxDelay: 5000,
jitter: false,
},
// Optimistischer Lock-Konflikt: sofort wiederholen
lock_conflict: {
maxAttempts: 3,
strategy: 'immediate',
baseDelay: 0,
maxDelay: 0,
jitter: false,
},
// Permanenter Fehler: nicht wiederholen
validation_error: {
maxAttempts: 1,
strategy: 'none',
baseDelay: 0,
maxDelay: 0,
jitter: false,
},
};
async function retryWithStrategy<T>(
fn: () => Promise<T>,
config: RetryConfig,
): Promise<T> {
let lastError: Error;
for (let attempt = 0; attempt < config.maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt === config.maxAttempts - 1) break;
if (config.strategy === 'none') break;
let delay = config.baseDelay;
if (config.strategy === 'exponential') {
delay = Math.min(config.baseDelay * Math.pow(2, attempt), config.maxDelay);
}
if (config.jitter) {
delay += Math.random() * delay * 0.5; // 0-50% Jitter
}
await sleep(delay);
}
}
throw lastError;
}
Jitter ist entscheidend. Ohne Jitter wiederholen alle Clients gleichzeitig nach einem Ausfall (Thundering Herd). Jitter verteilt die Retries über ein Zeitfenster und reduziert so die Belastungsspitze auf dem sich erholenden Service.
Graceful Degradation
Wenn eine Abhängigkeit ausfällt, liefere was du kannst, anstatt komplett zu versagen.
async function getProductPage(productId: string): Promise<ProductPageData> {
// Kerndaten: müssen erfolgreich sein
const product = await productService.getById(productId);
if (!product) throw new NotFoundError();
// Nicht-kritische Daten: graceful degradieren
const [reviews, recommendations, availability] = await Promise.allSettled([
reviewService.getForProduct(productId),
recommendationService.getSimilar(productId),
inventoryService.checkStock(productId),
]);
return {
product,
reviews: reviews.status === 'fulfilled' ? reviews.value : [],
recommendations: recommendations.status === 'fulfilled' ? recommendations.value : [],
availability: availability.status === 'fulfilled'
? availability.value
: { status: 'unknown', message: 'Check availability in store' },
};
}
Promise.allSettled ist der Schlüssel. Im Gegensatz zu Promise.all schlägt es nicht fehl, wenn ein Promise abgelehnt wird. Jedes Ergebnis wird unabhängig aufgelöst. Die Produktseite rendert mit den Daten, die verfügbar sind.
Veraltete Daten sind besser als keine Daten
async function getProductPrice(productId: string): Promise<PriceInfo> {
try {
const livePrice = await pricingService.getPrice(productId);
await cache.set(`price:${productId}`, livePrice, { ttl: 300 });
return livePrice;
} catch (error) {
// Pricing-Service ist down: gecachten Preis liefern
const cached = await cache.get(`price:${productId}`);
if (cached) {
return { ...cached, stale: true, staleSince: cached.cachedAt };
}
// Kein Cache vorhanden: Katalogpreis zurückgeben
const product = await productService.getById(productId);
return { price: product.listPrice, stale: true, approximate: true };
}
}
Ein veralteter Preis aus dem Cache ist besser als ein 500-Fehler. Ein ungefährer Katalogpreis ist besser als gar kein Preis. Hab immer einen Fallback, auch wenn er weniger genau ist.
Timeout-Budgets
Jede Operation hat ein Zeitbudget. Wenn das Budget überschritten wird, scheitere schnell, anstatt den Nutzer ewig warten zu lassen.
const TIMEOUT_BUDGETS = {
api_request: 5000, // 5s gesamt für jeden API-Request
database_query: 2000, // 2s für jede Datenbankabfrage
external_api: 3000, // 3s für externe Service-Aufrufe
llm_generation: 30000, // 30s für KI-Generierung (Streaming)
search_query: 1000, // 1s für Suche
cache_operation: 100, // 100ms für Cache-Lese-/Schreiboperationen
};
async function withTimeout<T>(promise: Promise<T>, budget: number, label: string): Promise<T> {
const timeout = new Promise<never>((_, reject) => {
setTimeout(() => reject(new TimeoutError(`${label} exceeded ${budget}ms budget`)), budget);
});
return Promise.race([promise, timeout]);
}
// Verwendung
const results = await withTimeout(
searchService.query(userQuery),
TIMEOUT_BUDGETS.search_query,
'product_search',
);
Kaskadierende Timeout-Absicherung
Wenn Service A Service B aufruft und Service B Service C aufruft, sollte jeder Service seine eigene Verarbeitungszeit vom verbleibenden Budget abziehen:
API Gateway (5s Budget)
└── Auth-Check (100ms verbraucht, 4,9s übrig)
└── Produkt-Service (200ms verbraucht, 4,7s übrig)
└── Pricing-Service (Timeout: 4,7s, nicht die ursprünglichen 5s)
Ohne kaskadierende Budgets nutzt der Pricing-Service sein volles 3s Timeout, obwohl das API Gateway nur noch 4,7s hat. Wenn Pricing 3s braucht, macht das Gateway einen Timeout bevor die Antwort ankommt, und die ganze Arbeit war umsonst.
Ausfälle testen
Chaos Engineering für kleine Teams
Du brauchst keinen Netflix Chaos Monkey, um Fehlerbehandlung zu testen. Fang mit einfacher Fault Injection an:
// Middleware: Fehler in Staging injizieren
function chaosMiddleware(req: Request, res: Response, next: NextFunction) {
if (process.env.NODE_ENV !== 'staging') return next();
const chaos = req.headers['x-chaos'];
if (chaos === 'latency') {
setTimeout(next, 3000); // 3s Latenz hinzufügen
} else if (chaos === 'error') {
res.status(500).json({ error: 'Chaos: injected failure' });
} else if (chaos === 'timeout') {
// Keine Antwort senden (hängenden Service simulieren)
} else {
next();
}
}
Testszenarien, die zählen:
| Szenario | Wie testen | Was verifizieren |
|---|---|---|
| Datenbank ist down | Datenbank in Staging stoppen | Circuit Breaker öffnet, gecachte Daten werden geliefert |
| Langsame Abhängigkeit | 5s Latenz injizieren | Timeout feuert, degradierte Antwort wird zurückgegeben |
| Queue ist voll | Queue mit Testnachrichten füllen | Backpressure wird angewandt, kein Datenverlust |
| Speicherdruck | Container-Memory limitieren | OOM-Handling, sauberer Neustart |
| Zertifikatsablauf | Kurzlebiges Zertifikat in Staging verwenden | Alert feuert vor Ablauf |
Häufige Fallstricke
-
Gleiche Retry-Strategie für alle Fehler. Timeouts brauchen Backoff. Ungültige Eingaben brauchen null Retries. Rate Limits brauchen festes Delay.
-
Circuit Breaker ohne Fallback. Den Circuit zu öffnen und 500 zurückzugeben ist keine Fehlertoleranz. Liefere gecachte Daten, degradierte Ergebnisse oder eine in die Queue gestellte Antwort.
-
Kein Jitter bei Retries. Alle Clients wiederholen gleichzeitig und überlasten den sich erholenden Service. Füge zufälligen Jitter hinzu.
-
Unendliche Timeouts. Ein Request, der ewig wartet, blockiert eine Verbindung und einen Thread. Jede Operation braucht ein Timeout-Budget.
-
Nur den Happy Path testen. Wenn du nie getestet hast, was passiert wenn die Datenbank down ist, weißt du nicht ob deine Fallbacks funktionieren.
-
Kaskadierende Ausfälle durch gemeinsame Abhängigkeiten. Wenn Services A, B und C alle von derselben Datenbank abhängen und die Datenbank langsam ist, werden alle drei Services langsam. Circuit Breaker auf gemeinsamen Abhängigkeiten verhindern die Kaskade.
Die wichtigsten Erkenntnisse
-
Fehler klassifizieren bevor du reagierst. Transient (Retry), permanent (schnell fehlschlagen), partiell (degradieren), kaskadierend (Circuit Break). Jeder Typ braucht eine andere Strategie.
-
Circuit Breaker brauchen Fallbacks. Den Circuit zu öffnen ist nicht die Lösung. Gecachte Daten, alternative Ergebnisse oder in die Queue gestellte Antworten zu liefern ist die Lösung.
-
Jitter verhindert den Thundering Herd. Retry-Delays randomisieren. Ohne Jitter machen synchronisierte Retries die Erholung schwerer.
-
Veraltete Daten sind besser als keine Daten. Ein gecachter Preis von vor 5 Minuten ist besser als ein 500-Fehler. Hab immer einen Fallback-Pfad.
-
Timeout-Budgets kaskadieren. Jeder Service in der Kette zieht seine Verarbeitungszeit vom verbleibenden Budget ab. Lass innere Services nicht mehr Zeit verbrauchen als der äußere Service noch hat.
-
Ausfälle in Staging testen. Einfache Fault Injection (Latenz, Fehler, hängende Verbindungen) verifiziert, dass deine Resilienzmuster tatsächlich funktionieren.
Wir designen resiliente Systeme im Rahmen unserer Custom Software und Cloud Praxis. Wenn du Hilfe mit Reliability Engineering brauchst, sprich mit unserem Team oder frag ein Angebot an.
Behandelte Themen
Verwandte Guides
KI-Fehlermodi: Ein Produktions-Engineering-Leitfaden
Technischer Leitfaden zu KI-Ausfaellen in der Produktion. Erfahre alles ueber Halluzinationen, Kontextgrenzen, Prompt Injection und Model Drift.
Guide lesenEvent-Driven Architecture in der Praxis: Was wirklich schiefgeht
Echte Event-Driven-Architecture-Muster aus der Produktion. Event Storms, bidirektionale Sync-Schleifen, Dead Letters, Idempotency Stores und die Wahl zwischen Kafka, RabbitMQ, BullMQ und Symfony Messenger.
Guide lesenUnternehmenshandbuch zu Agentischen KI-Systemen
Technischer Leitfaden zu agentischen KI-Systemen in Unternehmen. Erfahre mehr ueber Architektur, Faehigkeiten und Anwendungen autonomer KI-Agenten.
Guide lesenBereit, produktionsreife KI-Systeme zu bauen?
Unser Team ist spezialisiert auf produktionsreife KI-Systeme. Lass uns besprechen, wie wir deinem Unternehmen helfen können.
Gespräch starten