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
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.

Drei 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.
// 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.

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 { 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.

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.