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:
- Nutzerbezogene Limits: Verhindern, dass einzelne Nutzer:innen das System überlasten
- Globale Limits: Die Gesamtkapazität des Systems schützen
- 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
- Release It! von Michael Nygard
- Site Reliability Engineering von Google
- Designing Data-Intensive Applications von Martin Kleppmann