Resiliente APIs entwickeln: Muster für die Produktion

Einführung

APIs fallen aus. Netzwerke sind unzuverlässig, Abhängigkeiten fallen aus, und unerwartete Last tritt auf. Der Unterschied zwischen einer guten und einer großartigen API liegt darin, wie sie mit diesen Ausfällen umgeht.

Dieser Leitfaden behandelt praktische Muster, die ich zum Aufbau resilienter APIs in der Produktion verwendet habe.

Circuit-Breaker-Muster

Das Problem

Wenn ein nachgelagerter Service ausfällt, verursacht das fortgesetzte Aufrufen:

  • Ressourcenverschwendung
  • Erhöhte Latenz
  • Möglicherweise kaskadierende Ausfälle

Die Lösung

Ein Circuit Breaker überwacht Fehler und „öffnet” sich, wenn ein Schwellenwert erreicht wird, und gibt sofort Fehler zurück, statt den ausfallenden Service weiter aufzurufen.

class CircuitBreaker {
  private state: 'closed' | 'open' | 'half-open' = 'closed';
  private failureCount = 0;
  private lastFailureTime?: number;

  async execute<T>(fn: () => Promise<T>): Promise<T> {
    if (this.state === 'open') {
      if (this.shouldAttemptReset()) {
        this.state = 'half-open';
      } else {
        throw new Error('Circuit breaker is open');
      }
    }

    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }

  private onSuccess() {
    this.failureCount = 0;
    this.state = 'closed';
  }

  private onFailure() {
    this.failureCount++;
    this.lastFailureTime = Date.now();
    
    if (this.failureCount >= FAILURE_THRESHOLD) {
      this.state = 'open';
    }
  }

  private shouldAttemptReset(): boolean {
    return Date.now() - this.lastFailureTime! > RESET_TIMEOUT;
  }
}

Auswirkung in der Praxis

Nach der Einführung von Circuit Breakers für unsere Zahlungsanbieter-Integrationen:

  • 90 % weniger kaskadierende Ausfälle
  • Verbesserte API-Antwortzeiten bei Ausfällen von Anbietern
  • Bessere Sichtbarkeit in den Zustand der Abhängigkeiten

Wiederholung mit exponentiellem Backoff

Das Problem

Vorübergehende Ausfälle sind häufig (Netzwerkstörungen, kurzzeitige Überlastung). Sofortige Wiederholungen können die Situation verschlimmern.

Die Lösung

Wiederholung mit exponentiell steigenden Verzögerungen, plus Jitter, um einen „Thundering Herd”-Effekt zu vermeiden.

async function retryWithBackoff<T>(
  fn: () => Promise<T>,
  maxRetries = 3
): Promise<T> {
  let lastError: Error;
  
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error;
      
      if (attempt < maxRetries - 1) {
        const delay = Math.min(
          1000 * Math.pow(2, attempt) + Math.random() * 1000,
          10000
        );
        await sleep(delay);
      }
    }
  }
  
  throw lastError!;
}

Wann wiederholt werden sollte

Nicht alle Fehler sollten wiederholt werden:

  • ✅ Netzwerk-Timeouts
  • ✅ 503 Service Unavailable
  • ✅ 429 Too Many Requests
  • ❌ 400 Bad Request
  • ❌ 401 Unauthorized
  • ❌ 404 Not Found

Timeouts

Das Problem

Ohne Timeouts kann eine langsame Abhängigkeit die gesamte API blockieren.

Die Lösung

Aggressive Timeouts setzen und schnell fehlschlagen.

async function withTimeout<T>(
  promise: Promise<T>,
  timeoutMs: number
): Promise<T> {
  const timeout = new Promise<never>((_, reject) => {
    setTimeout(() => reject(new Error('Timeout')), timeoutMs);
  });
  
  return Promise.race([promise, timeout]);
}

Timeout-Werte wählen

  • P95-Latenz + Puffer: Wenn P95 200 ms beträgt, Timeout auf 500 ms setzen
  • Kaskadierende Timeouts berücksichtigen: Jede Ebene sollte ein kürzeres Timeout haben als die Ebene darüber
  • Überwachen und anpassen: Metriken nutzen, um Timeout-Werte zu optimieren

Graceful Degradation

Das Problem

Wenn eine unkritische Abhängigkeit ausfällt, soll dann die gesamte API ausfallen?

Die Lösung

Kritische und unkritische Abhängigkeiten identifizieren und elegant degradieren.

async function getUserProfile(userId: string) {
  const [user, preferences, recommendations] = await Promise.allSettled([
    fetchUser(userId),           // Kritisch
    fetchPreferences(userId),    // Unkritisch
    fetchRecommendations(userId) // Unkritisch
  ]);

  if (user.status === 'rejected') {
    throw new Error('Failed to fetch user');
  }

  return {
    user: user.value,
    preferences: preferences.status === 'fulfilled' 
      ? preferences.value 
      : null,
    recommendations: recommendations.status === 'fulfilled'
      ? recommendations.value
      : []
  };
}

Rate Limiting

Das Problem

Unbegrenzte Anfragen können die eigene API und nachgelagerte Services überlasten.

Die Lösung

Rate Limiting auf mehreren Ebenen implementieren:

  1. Nutzerbezogene Limits: Verhindern, dass einzelne Nutzer:innen das System überlasten
  2. Globale Limits: Die Gesamtkapazität des Systems schützen
  3. Abhängigkeits-Limits: Limits nachgelagerter Services respektieren
class RateLimiter {
  private requests = new Map<string, number[]>();

  async checkLimit(key: string, limit: number, windowMs: number): Promise<boolean> {
    const now = Date.now();
    const windowStart = now - windowMs;
    
    const requests = this.requests.get(key) || [];
    const recentRequests = requests.filter(time => time > windowStart);
    
    if (recentRequests.length >= limit) {
      return false;
    }
    
    recentRequests.push(now);
    this.requests.set(key, recentRequests);
    return true;
  }
}

Monitoring und Observability

Wesentliche Metriken

Verfolgen Sie diese Metriken für jeden API-Endpunkt:

  • Anfragerate
  • Fehlerrate
  • Latenz (P50, P95, P99)
  • Zustand der Abhängigkeiten

Strukturiertes Logging

Protokollieren Sie ausreichend Kontext, um Probleme zu debuggen:

logger.info('Payment processed', {
  userId,
  paymentId,
  amount,
  provider,
  duration: Date.now() - startTime,
  success: true
});

Fazit

Der Aufbau resilienter APIs erfordert, von Anfang an über Fehlerszenarien nachzudenken. Die hier behandelten Muster – Circuit Breaker, Retries, Timeouts, Graceful Degradation und Rate Limiting – bilden eine solide Grundlage.

Denken Sie daran: Ausfälle werden passieren. Das Ziel ist, sie elegant zu handhaben und auch dann eine gute Nutzererfahrung zu bewahren, wenn etwas schiefgeht.

Weiterführende Lektüre