Technischer Leitfaden

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.

31. März 202614 Min. LesezeitOronts Engineering Team

Die Fehler-Taxonomie

Systeme versagen auf vier Arten. Jede erfordert eine andere Reaktion.

TypBeschreibungBeispielKorrekte Reaktion
TransientKurzer Aussetzer, löst sich von selbstNetzwerk-Timeout, VerbindungsabbruchRetry mit Backoff
PermanentKaputt bis jemand es repariertUngültige Konfiguration, Schema-MismatchSchnell fehlschlagen, alarmieren, nicht wiederholen
PartiellEinige Funktionen arbeiten nochEiner von drei Zulieferern ist downGraceful Degradation, liefere was geht
KaskadierendEin Fehler löst weitere ausDatenbanküberlastung verursacht Timeouts in allen ServicesCircuit 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:

KontextWenn der Circuit öffnetKorrektes Verhalten
ProduktsucheEin Zulieferer ist downErgebnisse von anderen Zulieferern zeigen
PreisabfragePricing-Service ist downGecachten Preis mit "Stand: X" Label anzeigen
CheckoutPayment Gateway ist downBestellung in Queue stellen, später verarbeiten
EmpfehlungenML-Service ist downBeliebte Artikel stattdessen anzeigen
BilddienstCDN ist downPlatzhalter-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:

SzenarioWie testenWas verifizieren
Datenbank ist downDatenbank in Staging stoppenCircuit Breaker öffnet, gecachte Daten werden geliefert
Langsame Abhängigkeit5s Latenz injizierenTimeout feuert, degradierte Antwort wird zurückgegeben
Queue ist vollQueue mit Testnachrichten füllenBackpressure wird angewandt, kein Datenverlust
SpeicherdruckContainer-Memory limitierenOOM-Handling, sauberer Neustart
ZertifikatsablaufKurzlebiges Zertifikat in Staging verwendenAlert feuert vor Ablauf

Häufige Fallstricke

  1. Gleiche Retry-Strategie für alle Fehler. Timeouts brauchen Backoff. Ungültige Eingaben brauchen null Retries. Rate Limits brauchen festes Delay.

  2. 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.

  3. Kein Jitter bei Retries. Alle Clients wiederholen gleichzeitig und überlasten den sich erholenden Service. Füge zufälligen Jitter hinzu.

  4. Unendliche Timeouts. Ein Request, der ewig wartet, blockiert eine Verbindung und einen Thread. Jede Operation braucht ein Timeout-Budget.

  5. 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.

  6. 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

ResilienzmusterCircuit BreakerRetry-StrategieGraceful DegradationSystemzuverlässigkeitTimeout-BudgetChaos EngineeringDead Letter Queue

Bereit, 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