Zum Inhalt springen

Resiliente verteilte Systeme: Timeouts und Retries

Wie sich Timeouts, Retries und Backoff-Strategien implementieren lassen, die kaskadierende Ausfälle in verteilten Architekturen verhindern.

5 Min. Lesezeit
Sequenzdiagramm, das einen Retry mit exponential backoff zwischen zwei Services zeigt

In einem verteilten System kann jeder Netzwerkaufruf fehlschlagen, hängen bleiben oder um Größenordnungen länger dauern als erwartet. Ein Downstream-Service, der normalerweise in 50 ms antwortet, kann während eines Deployments plötzlich 30 Sekunden brauchen – oder überhaupt nicht mehr antworten. Ohne Timeouts und Retries kann ein einziger langsamer Service einen kompletten Systemausfall auslösen, weil Threads, Verbindungen und Speicher darauf warten, dass Antworten eintreffen, die nie kommen.

Die eigentliche Herausforderung besteht nicht darin, Timeouts und Retries einzubauen, sondern sie so zu konfigurieren, dass sie bei echten Vorfällen helfen, statt die Lage zu verschlimmern.

Timeouts: der nicht verhandelbare Standard

Jeder ausgehende Netzwerkaufruf braucht einen Timeout. Ohne Ausnahme. Ein fehlender Timeout macht aus einem vorübergehenden Downstream-Problem ein dauerhaftes Ressourcenleck im eigenen Service.

tstypescript
// ❌ No timeout — hangs indefinitely if downstream is slow
const response = await fetch('https://api.payment-provider.com/charge', {
  method: 'POST',
  body: JSON.stringify(payload),
});
 
// ✅ Explicit timeout — fails fast when downstream is unresponsive
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
 
try {
  const response = await fetch('https://api.payment-provider.com/charge', {
    method: 'POST',
    body: JSON.stringify(payload),
    signal: controller.signal,
  });
  return await response.json();
} catch (error) {
  if (error instanceof DOMException && error.name === 'AbortError') {
    throw new TimeoutError('Payment provider did not respond within 5s');
  }
  throw error;
} finally {
  clearTimeout(timeout);
}

Die richtigen Timeout-Werte wählen

tstypescript
interface TimeoutConfig {
  // Connect timeout: how long to wait for TCP connection
  connectTimeout: number;
  // Read timeout: how long to wait for response after connected
  readTimeout: number;
  // Total timeout: maximum wall-clock time for the entire operation
  totalTimeout: number;
}
 
// ❌ Timeout too high — defeats the purpose
const bad: TimeoutConfig = {
  connectTimeout: 60_000,
  readTimeout: 120_000,
  totalTimeout: 180_000,
};
 
// ✅ Based on p99 latency + margin
const good: TimeoutConfig = {
  connectTimeout: 1_000,    // Most connects finish in <100ms
  readTimeout: 3_000,       // p99 is 800ms, 3x margin
  totalTimeout: 5_000,      // Hard ceiling for the operation
};

Timeouts sollten sich an der beobachteten p99-Latenz des Downstream-Service orientieren, nicht am Durchschnitt. Ein Timeout beim 3-Fachen des p99-Werts fängt echte Fehler ab, ohne bei normalen Latenzschwankungen anzuschlagen.

Retry-Strategien

Retries fangen vorübergehende Fehler ab – kurze Netzwerkaussetzer, Neustarts eines Service, temporäre Überlastung. Naive Retries verstärken Probleme jedoch, statt sie zu lösen.

tstypescript
// ❌ Immediate retry — hammers a struggling service
async function naiveRetry<T>(fn: () => Promise<T>, maxRetries: number): Promise<T> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxRetries) throw error;
      // Retries instantly — adds load to an already struggling service
    }
  }
  throw new Error('Unreachable');
}
 
// ✅ Exponential backoff with jitter — backs off and spreads retry load
async function retryWithBackoff<T>(
  fn: () => Promise<T>,
  options: {
    maxRetries: number;
    baseDelayMs: number;
    maxDelayMs: number;
  }
): Promise<T> {
  const { maxRetries, baseDelayMs, maxDelayMs } = options;
 
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxRetries) throw error;
      if (!isRetryable(error)) throw error;
 
      const exponentialDelay = baseDelayMs * Math.pow(2, attempt);
      const jitter = Math.random() * exponentialDelay;
      const delay = Math.min(exponentialDelay + jitter, maxDelayMs);
 
      await sleep(delay);
    }
  }
  throw new Error('Unreachable');
}

Jitter verhindert den Thundering-Herd-Effekt

Ohne Jitter versuchen es alle Clients gleichzeitig erneut – ein synchronisierter Ansturm, der den sich erholenden Service überfordert.

tstypescript
// Without jitter: 1000 clients all retry at exactly t+1s, t+2s, t+4s
// With jitter: 1000 clients retry spread over [0-1s], [0-2s], [0-4s]
 
function calculateBackoff(attempt: number, baseMs: number, maxMs: number): number {
  // Full jitter — recommended by AWS
  const ceiling = Math.min(maxMs, baseMs * Math.pow(2, attempt));
  return Math.random() * ceiling;
}
 
// Decorrelated jitter — even better spread
function decorrelatedJitter(
  previousDelay: number,
  baseMs: number,
  maxMs: number
): number {
  return Math.min(maxMs, baseMs + Math.random() * (previousDelay * 3 - baseMs));
}

Retry-Budgets

In einer Microservice-Kette multiplizieren sich Retries. Ruft A den Service B mit 3 Retries auf und B wiederum C mit 3 Retries, erzeugt ein Fehler bei C bis zu 9 Requests von B und 27 von A. Diese Verstärkung kann aus einem kleinen Fehler eine systemweite Überlastung machen.

tstypescript
class RetryBudget {
  private attempts = 0;
  private successes = 0;
 
  // Only retry if retry rate is below threshold
  canRetry(): boolean {
    if (this.attempts === 0) return true;
 
    const retryRate = 1 - (this.successes / this.attempts);
    return retryRate < 0.2; // Max 20% retry rate
  }
 
  recordAttempt(): void {
    this.attempts++;
  }
 
  recordSuccess(): void {
    this.successes++;
  }
 
  // Reset counters periodically
  reset(): void {
    this.attempts = 0;
    this.successes = 0;
  }
}

Ein Retry-Budget begrenzt die gesamte Retry-Rate über alle Requests hinweg, nicht pro einzelnem Request. Solange das System gesund ist, fällt das Budget großzügig aus. Steigen die Fehler sprunghaft an, wird das Budget enger – und verhindert so Retry-Stürme.

Idempotenz für sichere Retries

Retries sind nur sicher, wenn die Operation idempotent ist – sie also zweimal ausgeführt dasselbe Ergebnis liefert wie einmal. Ohne Idempotenz können Retries Zahlungen doppelt auslösen, doppelte Einträge anlegen oder E-Mails mehrfach verschicken.

tstypescript
// ❌ Not idempotent — retry creates duplicate payment
app.post('/charge', async (req, res) => {
  const result = await paymentProvider.charge(req.body.amount);
  await db.payments.create({ amount: req.body.amount, txId: result.id });
  return res.json({ success: true });
});
 
// ✅ Idempotent — uses idempotency key to prevent duplicates
app.post('/charge', async (req, res) => {
  const idempotencyKey = req.headers['idempotency-key'] as string;
  if (!idempotencyKey) {
    return res.status(400).json({ error: 'Idempotency-Key header required' });
  }
 
  // Check if this request was already processed
  const existing = await db.idempotencyKeys.findUnique({
    where: { key: idempotencyKey },
  });
  if (existing) {
    return res.json(existing.response);
  }
 
  const result = await paymentProvider.charge(req.body.amount);
  const response = { success: true, txId: result.id };
 
  await db.idempotencyKeys.create({
    data: { key: idempotencyKey, response },
  });
 
  return res.json(response);
});

Timeout-Hierarchie in Service-Ketten

In einer Aufrufkette (API Gateway → Service A → Service B → Datenbank) müssen die Timeouts mit jedem Hop kleiner werden. Hat Service A einen Timeout von 5 s und Service B einen von 10 s, arbeitet B womöglich noch, während A längst aufgegeben hat – verschwendete Ressourcen.

tstypescript
// Service chain timeout configuration
const timeouts = {
  apiGateway: 10_000,   // 10s — highest timeout, user-facing
  serviceA:    7_000,   //  7s — must finish before gateway timeout
  serviceB:    4_000,   //  4s — must finish before A's timeout
  database:    2_000,   //  2s — must finish before B's timeout
};
 
// Each level leaves headroom for retries and processing
// Gateway (10s) > A (7s) > B (4s) > DB (2s)

Deadline-Propagation

Anstatt dass jeder Service unabhängig eigene Timeouts festlegt, wird eine Deadline von der ursprünglichen Anfrage aus weitergereicht.

tstypescript
interface RequestContext {
  deadline: number; // Unix timestamp when the entire chain must complete
  correlationId: string;
}
 
async function callDownstream(ctx: RequestContext, url: string): Promise<Response> {
  const remainingMs = ctx.deadline - Date.now();
 
  if (remainingMs <= 0) {
    throw new DeadlineExceeded('No time remaining for downstream call');
  }
 
  // Use remaining time as timeout, with safety margin
  const timeout = Math.max(remainingMs - 100, 0);
 
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeout);
 
  try {
    return await fetch(url, {
      headers: {
        'X-Request-Deadline': String(ctx.deadline),
        'X-Correlation-Id': ctx.correlationId,
      },
      signal: controller.signal,
    });
  } finally {
    clearTimeout(timer);
  }
}

Deadline-Propagation stellt sicher, dass Downstream-Services keine Arbeit beginnen, die sie nicht abschließen können, bevor der Aufrufer aufgibt.

Die wichtigsten Erkenntnisse

  1. Jeder Netzwerkaufruf braucht einen Timeout – ein fehlender Timeout macht aus einem vorübergehenden Fehler eine dauerhafte Ressourcenerschöpfung
  2. Timeouts am p99-Wert ausrichten, nicht am Durchschnitt – auf das 2- bis 3-Fache der beobachteten p99-Latenz setzen
  3. Immer exponential backoff mit Jitter einsetzen – verhindert einen Thundering-Herd-Effekt beim sich erholenden Service
  4. Retry-Budgets verhindern Verstärkungseffekte – sie begrenzen die gesamte Retry-Rate, nicht nur einzelne Retries pro Request
  5. Retries setzen Idempotenz voraus – ohne sie verdoppeln Retries Nebeneffekte
  6. Timeouts nehmen entlang der Aufrufkette ab – die Deadline-Propagation erzwingt das automatisch
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX