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.
Die Signatur
Abschnitt betitelt „Die Signatur“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 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.
Das Backoff konfigurieren
Abschnitt betitelt „Das Backoff konfigurieren“Drei Knöpfe kooperieren zur Steuerung des Delays zwischen Versuchen:
| Setting | Was es tut |
|---|---|
delayMs | Base-Delay zwischen Versuchen. Default 0 (kein Warten). |
factor | Multiplikator pro Versuch. Delay bei Versuch N ist delayMs × factor^(N-1). Default 1 (konstant). |
maxDelayMs | Obergrenze für jedes einzelne Delay. Default unbegrenzt. |
// 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 });Kein Jitter — das Delay ist deterministisch pro Versuch. Wenn du gejitterte Retries brauchst (empfohlen für High-Concurrency-Szenarien, die synchronisieren könnten), wickle die Policy:
import { retry, exponentialBackoff } from 'actor-ts';
const policy = exponentialBackoff({ minMs: 200, maxMs: 5_000, randomFactor: 0.2 });
async function jitteredRetry<T>(factory: () => Promise<T>, attempts: number): Promise<T> { for (let i = 0; i < attempts; i++) { try { return await factory(); } catch (e) { if (i === attempts - 1) throw e; await new Promise(r => setTimeout(r, policy.delayFor(i))); } } throw new Error('unreachable');}Oder verwende BackoffSupervisor, wenn das, was retried wird, ein
Actor-Restart ist.
Selektives Retry — shouldRetry
Abschnitt betitelt „Selektives Retry — shouldRetry“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.
Observability — onAttempt
Abschnitt betitelt „Observability — onAttempt“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.”
Das Backoff testen — sleep
Abschnitt betitelt „Das Backoff testen — sleep“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 { ManualScheduler, retry } from 'actor-ts';
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 bei
exponentialBackoff — die
Nichtdeterminismus-Quelle injizieren und dann festnageln.
Verglichen mit BackoffSupervisor
Abschnitt betitelt „Verglichen mit BackoffSupervisor“| Use Case | Greife zu… |
|---|---|
Ein einzelner Promise-zurückgebender Call (HTTP-Fetch, DB-Query, Ask) | retry |
| Ein Actor, der fehlschlägt und respawned werden soll | BackoffSupervisor |
| Ein Call, geschützt durch Kurzschlussverhalten | CircuitBreaker (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.
Verglichen mit Circuit Breaker
Abschnitt betitelt „Verglichen mit Circuit Breaker“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.
Wie es weitergeht
Abschnitt betitelt „Wie es weitergeht“- Backoff-Supervisor — das Actor-Restart-Äquivalent zu Retry.
- Backoff-Policy — reine Delay-Berechnungs-Primitives für eigene Retry-Loops.
- Circuit Breaker — das komplementäre “Hör eine Weile auf zu rufen”-Primitive.
Die retry-API-Referenz deckt die volle
Signatur ab.
