Zum Inhalt springen
Deutsch

Retry

retry ist eine reine Async-Funktion für “versuche diesen Call bis zu N-mal, mit Backoff zwischen den Versuchen.” Es ist die einfachste Schicht im Resilienz-Stack — keine Actors, kein Zustand, nur eine Promise-zurückgebende Factory.

import { retry } from 'actor-ts';
const data = await retry(
() => fetch('https://example.com/items').then(r => r.json()),
{ attempts: 3, delayMs: 200, factor: 2 },
);

Die Factory wird bis zu 3-mal aufgerufen. Jeder Retry wartet länger: Versuch 1 sofort, Versuch 2 nach 200 ms, Versuch 3 nach 400 ms. Gibt den ersten Erfolg zurück; rejected mit dem letzten Fehler, wenn jeder Versuch fehlschlägt.

function retry<T>(factory: () => Promise<T>, options: RetryOptions): Promise<T>;
type RetryOptions = {
attempts: number; // gesamt inklusive initialer Call (>= 1)
delayMs?: number; // Base-Delay zwischen Versuchen
factor?: number; // Multiplikator für Exponential-Backoff (Default 1)
maxDelayMs?: number; // Obergrenze für jedes einzelne Delay
randomFactor?: number; // Jitter-Anteil in [0, 1] (Default 0 — keiner)
random?: () => number; // Math.random in Tests überschreiben
shouldRetry?: (err: Error, attempt: number) => boolean;
onAttempt?: (err: Error, attempt: number) => void;
sleep?: (ms: number) => Promise<void>; // wie das Delay gewartet wird
};

Das attempts-Feld ist Gesamt-Versuche, nicht Retries — attempts: 1 führt die Factory genau einmal ohne Retries aus. attempts: 3 läuft bis zu 3-mal.

Vier Knöpfe kooperieren zur Steuerung des Delays zwischen Versuchen:

SettingWas es tut
delayMsBase-Delay zwischen Versuchen. Default 0 (kein Warten).
factorMultiplikator pro Versuch. Delay bei Versuch N ist delayMs × factor^(N-1). Default 1 (konstant).
maxDelayMsObergrenze für jedes einzelne Delay. Default unbegrenzt — aber nie über das 32-Bit-Timer-Limit hinaus, auf das retry bedingungslos klemmt (siehe den Overflow-Hinweis am Ende dieser Seite).
randomFactorJitter-Anteil in [0, 1]. Das Delay wird auf Delay × (1 ± randomFactor) gestreut. Default 0 (kein Jitter).
// Konstant 500ms zwischen Versuchen:
retry(factory, { attempts: 5, delayMs: 500 });
// Exponentiell: 200ms, 400ms, 800ms, gecappt bei 5s:
retry(factory, { attempts: 6, delayMs: 200, factor: 2, maxDelayMs: 5_000 });
// Fibonacci-artig: ad-hoc via factor=1.6:
retry(factory, { attempts: 5, delayMs: 100, factor: 1.6 });

Ohne Jitter ist das Delay eine reine Funktion der Versuchsnummer — jeder Caller, der am selben Upstream-Ereignis gescheitert ist, kommt also in derselben Millisekunde zurück, Welle für Welle, und die synchronisierte Herde kann einen sich erholenden Service unten halten. randomFactor streut jedes Delay auf Delay × (1 ± randomFactor):

// 200ms, 400ms, 800ms … jeweils ±20%, gecappt bei 5s:
retry(factory, {
attempts: 6,
delayMs: 200,
factor: 2,
maxDelayMs: 5_000,
randomFactor: 0.2,
});

Der Default ist 0, ein vor dieser Option geschriebenes retry behält also seinen exakten Zeitplan. Schalte es ein, sobald mehr als ein Caller am selben Ereignis scheitern kann — eine Flotte von Pods an einer Datenbank, ein Fan-out von Workern hinter einer API. 0.2 ist die Streuung, die exponentialBackoff und die Broker-Reconnect-Schleife beide als Default verwenden.

random überschreibt Math.random, ein gejitterter Zeitplan bleibt in einem Test also exakt prüfbar — dieselbe Hintertür wie sleep weiter unten.

Oder verwende BackoffSupervisor, wenn das, was retried wird, ein Actor-Restart ist.

class TransientError extends Error {}
class PermanentError extends Error {}
await retry(
() => callExternalAPI(),
{
attempts: 5,
delayMs: 500,
factor: 2,
shouldRetry: (err) => err instanceof TransientError,
},
);

shouldRetry läuft nach jedem fehlgeschlagenen Versuch außer dem letzten. Gib false zurück, um kurzzuschließen — die Retry-Loop endet sofort mit diesem Fehler, keine weiteren Versuche.

Verwende es, um zwischen transienten und permanenten Fehlern zu unterscheiden:

  • Transient (Netzwerk-Aussetzer, Rate-Limit, Lock-Contention) → retry.
  • Permanent (Validierungsfehler, Auth-Fehler, 4xx) → nicht retrien; der nächste Versuch wird auf dieselbe Weise fehlschlagen.
await retry(factory, {
attempts: 3,
delayMs: 500,
onAttempt: (err, attempt) => {
metrics.counter('retry.attempt').inc({ attempt });
log.warn(`attempt ${attempt} failed: ${err.message}`);
},
});

Feuert nach jedem fehlgeschlagenen Versuch, inklusive dem letzten. Verwende es für retry-bewusste Metriken und Logging — Counter pro Versuch, Spans fürs Tracing, Alerts auf “uns sind die Retries ausgegangen.”

sleep entscheidet, wie das Delay zwischen Versuchen gewartet wird. Default ist setTimeout, in Produktion brauchst du es also nie — überschreibe es in Tests, um die Retry-Schleife von der Wall-Clock zu lösen:

import { retry } from 'actor-ts';
import { ManualScheduler } from 'actor-ts/testkit';
const scheduler = new ManualScheduler();
const attemptTimes: number[] = [];
await retry(
async () => { attemptTimes.push(scheduler.now()); throw new Error('fail'); },
{
attempts: 3,
delayMs: 20,
factor: 2,
maxDelayMs: 30,
sleep: (ms) => new Promise<void>((resolve) => {
scheduler.scheduleOnceFunction(ms, resolve);
scheduler.advance(ms);
}),
},
).catch(() => { /* erwartet */ });
// Exakt 20ms, dann 40ms geklemmt auf maxDelayMs — und instant.
expect(attemptTimes).toEqual([0, 20, 50]);

Jeder Sleep löst in dem Moment auf, in dem die virtuelle Zeit ihn erreicht — der Zeitplan wird also exakt geprüft und der Test kostet keine Wall-Clock-Zeit. Stattdessen echte Abstände zu messen ist ein Flake mit Anlauf: ein 30-ms-setTimeout landet je nach Timer-Quantum der Plattform und Maschinenlast irgendwo zwischen etwa 19 ms und 200 ms — breit genug, dass ein gecapptes Delay von einem ungecappten nicht mehr zu unterscheiden ist.

Dieselbe Hintertür wie random weiter oben und wie bei exponentialBackoff — die Nichtdeterminismus-Quelle injizieren und dann festnageln.

Use CaseGreife zu…
Ein einzelner Promise-zurückgebender Call (HTTP-Fetch, DB-Query, Ask)retry
Ein Actor, der fehlschlägt und respawned werden sollBackoffSupervisor
Ein Call, geschützt durch KurzschlussverhaltenCircuitBreaker (oft kombiniert mit retry innen)

retry ist das Call-Level-Primitive. BackoffSupervisor ist das Actor-Level-Primitive. Sie konkurrieren nicht — wähle, was du schützt.

Für einen Actor, der seinen eigenen Downstream-Call retrien will, ist retry innerhalb von onReceive okay:

class MyActor extends Actor<...> {
override async onReceive(message): Promise<void> {
const result = await retry(
() => this.callDownstream(message),
{ attempts: 3, delayMs: 200, factor: 2 },
);
// ...
}
}

Innerhalb von onReceive zu awaiten, blockiert die Mailbox des Actors, bis der Retry vollendet ist — derselbe Trade-off wie jedes Await innerhalb eines onReceive. Siehe Ask-Pattern dafür, wann das ein Problem ist.

Ein Circuit Breaker ist Per-Dependency-Zustand, der sagt “höre für eine Weile auf, dieses Ding zu versuchen.” Retry ist Per-Call-Logik, die sagt “versuche jetzt nach einem kurzen Warten erneut.”

Kombiniere sie — Retry innerhalb eines Breaker-Calls:

breaker.call(() => retry(
() => fetch('https://flaky.example'),
{ attempts: 3, delayMs: 100, factor: 2 },
));

Der Retry handhabt transiente Aussetzer während eines einzelnen Calls; der Breaker trackt den Trend über viele Calls.

Die retry-API-Referenz deckt die volle Signatur ab.