Initial Commit
This commit is contained in:
108
packages/foundation/flow/workflow/src/middleware/hook/hook.md
Normal file
108
packages/foundation/flow/workflow/src/middleware/hook/hook.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# hook
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`HookRegistry` is the lifecycle-event dispatcher for `@suaveplan/workflow`.
|
||||
It is the seam between the engine's internals and any observer that needs
|
||||
to react to workflow or step transitions (logging, OpenTelemetry spans
|
||||
that aren't built in, push-notifications, audit trails). It deliberately
|
||||
keeps the surface to four operations — `on`, `once`, `off`, `emit` — so
|
||||
the engine and its consumers depend on a stable contract.
|
||||
|
||||
Every other module in the package emits *through* this class rather than
|
||||
acquiring its own listeners-map, so the registry is the canonical extension
|
||||
point for downstream tooling.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-topic listener registration with insertion-order preservation.
|
||||
- Returned unsubscribe function from `on` for ergonomic cleanup.
|
||||
- `once(event, listener)` auto-unregisters after the first emit.
|
||||
- `off(event?)` removes one topic or every topic.
|
||||
- Sequential async `emit` — handlers may be async; the next handler waits.
|
||||
- `listenerCount`, `events`, `totalListeners` introspection accessors.
|
||||
- Injectable `Clock` controls the `timestamp` on emitted payloads.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createHookRegistry } from "@suaveplan/workflow";
|
||||
|
||||
const hooks = createHookRegistry<{ orderId: string }>();
|
||||
hooks.on("workflow:start", (p) => {
|
||||
console.log("starting", p.workflowId);
|
||||
});
|
||||
|
||||
await hooks.emit("workflow:start", {
|
||||
workflowId: "wf-1",
|
||||
context: { orderId: "ord-1" },
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildStepPayload,
|
||||
createHookRegistry,
|
||||
} from "@suaveplan/workflow";
|
||||
|
||||
const hooks = createHookRegistry<{ user: string }>();
|
||||
|
||||
const unsubscribe = hooks.on("step:fail", async (p) => {
|
||||
if (p.stepResult && p.stepResult.attempts >= 3) {
|
||||
await pageOnCall(p.error);
|
||||
}
|
||||
});
|
||||
|
||||
hooks.once("workflow:complete", (p) => {
|
||||
console.log("done", p.workflowId, p.timestamp);
|
||||
});
|
||||
|
||||
await hooks.emit("step:fail", buildStepPayload(
|
||||
"wf-1",
|
||||
{ user: "alice" },
|
||||
"step-1",
|
||||
{
|
||||
stepId: "step-1",
|
||||
status: "failed",
|
||||
startedAt: 0,
|
||||
completedAt: 100,
|
||||
attempts: 3,
|
||||
},
|
||||
new Error("boom"),
|
||||
));
|
||||
|
||||
unsubscribe();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `HookRegistry<TCtx>` (constructor takes optional `{ clock }`) —
|
||||
- `on(event, listener): () => void` — returns unsubscribe
|
||||
- `once(event, listener): void`
|
||||
- `off(event?): void`
|
||||
- `emit(event, data): Promise<void>` — fires listeners sequentially
|
||||
- `listenerCount(event): number`
|
||||
- `events: HookEvent[]`
|
||||
- `totalListeners: number`
|
||||
- `createHookRegistry<TCtx>(options?): HookRegistry<TCtx>`.
|
||||
- `buildStepPayload<TCtx>(workflowId, context, stepId, stepResult?, error?): Omit<HookPayload, "event" | "timestamp">`
|
||||
— assembles the fixed part of a step payload so callers do not
|
||||
hand-build the shape.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`emit` short-circuits before allocating the payload object when no
|
||||
listeners are registered for the topic — workflows that run thousands of
|
||||
steps benefit from this for hot topics like `step:start` / `step:complete`.
|
||||
|
||||
`once` wraps the user listener in a closure that unregisters before
|
||||
invoking the underlying function, so even synchronous re-emits within the
|
||||
listener body cannot trigger the same listener twice. The
|
||||
unsubscribe-function returned from the wrapping `on` call is the cleanup
|
||||
seam.
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { buildStepPayload, createHookRegistry, HookRegistry } from "./hook.js";
|
||||
|
||||
describe("HookRegistry", () => {
|
||||
it("invokes listeners in registration order", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const order: number[] = [];
|
||||
const h = new HookRegistry<Record<string, never>>();
|
||||
h.on("workflow:start", () => {
|
||||
order.push(1);
|
||||
});
|
||||
h.on("workflow:start", () => {
|
||||
order.push(2);
|
||||
});
|
||||
await h.emit("workflow:start", { workflowId: "w", context: {} });
|
||||
expect(order).toEqual([1, 2]);
|
||||
expect(h.listenerCount("workflow:start")).toBe(2);
|
||||
expect(h.totalListeners).toBe(2);
|
||||
expect(h.events).toEqual(["workflow:start"]);
|
||||
});
|
||||
|
||||
it("unsubscribe removes the listener", { timeout: 5000 }, async () => {
|
||||
const h = new HookRegistry<Record<string, never>>();
|
||||
let calls = 0;
|
||||
const off = h.on("step:start", () => {
|
||||
calls++;
|
||||
});
|
||||
await h.emit("step:start", { workflowId: "w", context: {} });
|
||||
off();
|
||||
await h.emit("step:start", { workflowId: "w", context: {} });
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("unsubscribe is safe if listener already removed", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = new HookRegistry<Record<string, never>>();
|
||||
const off = h.on("step:start", () => {});
|
||||
off();
|
||||
off(); // double off — must not throw
|
||||
expect(h.listenerCount("step:start")).toBe(0);
|
||||
});
|
||||
|
||||
it("unsubscribe is safe after off(event) clears the topic", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const h = new HookRegistry<Record<string, never>>();
|
||||
const off = h.on("step:start", () => {});
|
||||
h.off("step:start");
|
||||
off(); // listener map has no entry now — must not throw
|
||||
expect(h.listenerCount("step:start")).toBe(0);
|
||||
});
|
||||
|
||||
it("once listener auto-unregisters after first emit", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createHookRegistry<Record<string, never>>();
|
||||
let calls = 0;
|
||||
h.once("step:complete", () => {
|
||||
calls++;
|
||||
});
|
||||
await h.emit("step:complete", { workflowId: "w", context: {} });
|
||||
await h.emit("step:complete", { workflowId: "w", context: {} });
|
||||
expect(calls).toBe(1);
|
||||
expect(h.listenerCount("step:complete")).toBe(0);
|
||||
});
|
||||
|
||||
it("off(event) removes only that topic", { timeout: 5000 }, () => {
|
||||
const h = new HookRegistry<Record<string, never>>();
|
||||
h.on("step:start", () => {});
|
||||
h.on("step:complete", () => {});
|
||||
h.off("step:start");
|
||||
expect(h.listenerCount("step:start")).toBe(0);
|
||||
expect(h.listenerCount("step:complete")).toBe(1);
|
||||
});
|
||||
|
||||
it("off() clears all listeners", { timeout: 5000 }, () => {
|
||||
const h = new HookRegistry<Record<string, never>>();
|
||||
h.on("step:start", () => {});
|
||||
h.on("step:complete", () => {});
|
||||
h.off();
|
||||
expect(h.totalListeners).toBe(0);
|
||||
});
|
||||
|
||||
it("emit is a no-op when no listeners", { timeout: 5000 }, async () => {
|
||||
const h = new HookRegistry<Record<string, never>>();
|
||||
await h.emit("workflow:start", { workflowId: "w", context: {} });
|
||||
// no throw
|
||||
expect(h.totalListeners).toBe(0);
|
||||
});
|
||||
|
||||
it("emit awaits async listeners sequentially", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const order: string[] = [];
|
||||
const h = new HookRegistry<Record<string, never>>();
|
||||
h.on("workflow:start", async () => {
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
order.push("a");
|
||||
});
|
||||
h.on("workflow:start", () => {
|
||||
order.push("b");
|
||||
});
|
||||
await h.emit("workflow:start", { workflowId: "w", context: {} });
|
||||
expect(order).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("includes timestamp from injected clock", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = new HookRegistry<Record<string, never>>({ clock: () => 99 });
|
||||
let ts: number | undefined;
|
||||
h.on("workflow:start", (p) => {
|
||||
ts = p.timestamp;
|
||||
});
|
||||
await h.emit("workflow:start", { workflowId: "w", context: {} });
|
||||
expect(ts).toBe(99);
|
||||
});
|
||||
|
||||
it("buildStepPayload builds the expected shape", { timeout: 5000 }, () => {
|
||||
const stepResult = {
|
||||
stepId: "s",
|
||||
status: "completed" as const,
|
||||
startedAt: 0,
|
||||
completedAt: 1,
|
||||
attempts: 1,
|
||||
};
|
||||
const err = new Error("e");
|
||||
const payload = buildStepPayload("w", { x: 1 }, "s", stepResult, err);
|
||||
expect(payload).toEqual({
|
||||
workflowId: "w",
|
||||
context: { x: 1 },
|
||||
stepId: "s",
|
||||
stepResult,
|
||||
error: err,
|
||||
});
|
||||
const minimal = buildStepPayload("w", {}, "s");
|
||||
expect(minimal.stepResult).toBeUndefined();
|
||||
expect(minimal.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
103
packages/foundation/flow/workflow/src/middleware/hook/hook.ts
Normal file
103
packages/foundation/flow/workflow/src/middleware/hook/hook.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import type {
|
||||
HookEvent,
|
||||
HookListener,
|
||||
HookPayload,
|
||||
StepResult,
|
||||
} from "../../types/common.js";
|
||||
import { defaultClock, now } from "../../utils/utils.js";
|
||||
|
||||
/**
|
||||
* Registry that manages hook listeners and dispatches events during
|
||||
* workflow execution.
|
||||
*/
|
||||
export class HookRegistry<TCtx = unknown> {
|
||||
private readonly _listeners = new Map<HookEvent, HookListener<TCtx>[]>();
|
||||
private readonly _clock: Clock;
|
||||
|
||||
constructor(options?: { clock?: Clock }) {
|
||||
this._clock = options?.clock ?? defaultClock;
|
||||
}
|
||||
|
||||
on(event: HookEvent, listener: HookListener<TCtx>): () => void {
|
||||
const existing = this._listeners.get(event) ?? [];
|
||||
existing.push(listener);
|
||||
this._listeners.set(event, existing);
|
||||
|
||||
return () => {
|
||||
const current = this._listeners.get(event);
|
||||
if (current) {
|
||||
const idx = current.indexOf(listener);
|
||||
if (idx >= 0) current.splice(idx, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
once(event: HookEvent, listener: HookListener<TCtx>): void {
|
||||
const wrapped: HookListener<TCtx> = (payload) => {
|
||||
unregister();
|
||||
return listener(payload);
|
||||
};
|
||||
const unregister = this.on(event, wrapped);
|
||||
}
|
||||
|
||||
off(event?: HookEvent): void {
|
||||
if (event) {
|
||||
this._listeners.delete(event);
|
||||
} else {
|
||||
this._listeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
async emit(
|
||||
event: HookEvent,
|
||||
data: Omit<HookPayload<TCtx>, "event" | "timestamp">
|
||||
): Promise<void> {
|
||||
const listeners = this._listeners.get(event);
|
||||
if (!listeners || listeners.length === 0) return;
|
||||
|
||||
const payload: HookPayload<TCtx> = {
|
||||
...data,
|
||||
event,
|
||||
timestamp: now(this._clock),
|
||||
};
|
||||
|
||||
for (const listener of listeners) {
|
||||
await listener(payload);
|
||||
}
|
||||
}
|
||||
|
||||
listenerCount(event: HookEvent): number {
|
||||
return this._listeners.get(event)?.length ?? 0;
|
||||
}
|
||||
|
||||
get events(): HookEvent[] {
|
||||
return [...this._listeners.keys()];
|
||||
}
|
||||
|
||||
get totalListeners(): number {
|
||||
let count = 0;
|
||||
for (const listeners of this._listeners.values()) {
|
||||
count += listeners.length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
/** Factory function to create a hook registry. */
|
||||
export function createHookRegistry<TCtx = unknown>(options?: {
|
||||
clock?: Clock;
|
||||
}): HookRegistry<TCtx> {
|
||||
return new HookRegistry(options);
|
||||
}
|
||||
|
||||
/** Helper to build a step-related hook payload. */
|
||||
export function buildStepPayload<TCtx>(
|
||||
workflowId: string,
|
||||
context: TCtx,
|
||||
stepId: string,
|
||||
stepResult?: StepResult,
|
||||
error?: Error
|
||||
): Omit<HookPayload<TCtx>, "event" | "timestamp"> {
|
||||
return { workflowId, context, stepId, stepResult, error };
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
# retry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`withRetry` is the standalone, reusable retry executor that
|
||||
`@suaveplan/workflow` exposes alongside the `Step.retry` configuration.
|
||||
Where the step's built-in retry is bound to the step's lifecycle (it only
|
||||
fires inside `Step.run`), `withRetry` can wrap any async function and is
|
||||
intended for the moments callers want retry semantics outside the
|
||||
workflow engine — e.g. retrying a manual cleanup, retrying a request
|
||||
inside a custom step that doesn't quite fit `Step`, or building higher-level
|
||||
retry-with-circuit-breaker compositions.
|
||||
|
||||
The module also re-exports the `RetryPresets` object so callers can pick
|
||||
sensible defaults (`quick`, `standard`, `aggressive`) without duplicating
|
||||
the numbers in every consumer.
|
||||
|
||||
## Features
|
||||
|
||||
- `withRetry(fn, options?)` — retries with the resolved config.
|
||||
- `createRetryWrapper(options?)` — reusable closure capturing options.
|
||||
- `resolveRetryConfig(partial?)` — fills in defaults from
|
||||
`constants/index`.
|
||||
- `RetryPresets.{none, quick, standard, aggressive}` — pre-baked configs.
|
||||
- Hooks: `onRetry(err, attempt)` before each backoff, `shouldRetry(err,
|
||||
attempt)` to short-circuit early.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { withRetry, RetryPresets } from "@suaveplan/workflow";
|
||||
|
||||
const result = await withRetry(
|
||||
() => fetch("/api/data").then((r) => r.json()),
|
||||
RetryPresets.standard(),
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createRetryWrapper } from "@suaveplan/workflow";
|
||||
|
||||
const wrap = createRetryWrapper({
|
||||
maxAttempts: 4,
|
||||
strategy: "exponential",
|
||||
delayMs: 100,
|
||||
maxDelayMs: 2_000,
|
||||
shouldRetry: (err) => !(err instanceof TypeError),
|
||||
onRetry: (err, attempt) => {
|
||||
console.warn(`attempt ${attempt} failed:`, err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const json = await wrap(() => fetch("/api/v1").then((r) => r.json()));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RetryOptions extends Partial<RetryConfig>` —
|
||||
- `shouldRetry?: (error, attempt) => boolean`
|
||||
- `onRetry?: (error, attempt) => void`
|
||||
- `resolveRetryConfig(options?: Partial<RetryConfig>): RetryConfig`.
|
||||
- `withRetry<T>(fn: () => T | Promise<T>, options?: RetryOptions): Promise<T>`.
|
||||
- `createRetryWrapper(options?: RetryOptions): <T>(fn) => Promise<T>`.
|
||||
- `RetryPresets` — `{ none, quick, standard, aggressive }`, each a function
|
||||
returning a fresh `RetryConfig`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`withRetry` always rethrows the *last* error, not the *first*. Callers
|
||||
that want the first-error semantics can capture it inside an `onRetry`
|
||||
closure. The loop checks `attempt >= maxAttempts` before `shouldRetry` so
|
||||
the final attempt always actually runs even if `shouldRetry` would have
|
||||
returned false — the contract is "make all the attempts you promised."
|
||||
|
||||
`RetryPresets` are factory functions rather than constants so each call
|
||||
returns a fresh object. That keeps callers from accidentally mutating a
|
||||
shared preset (e.g. by spreading-and-overriding) and seeing the change
|
||||
ripple through the rest of the application.
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { WORKFLOW_CODES, WorkflowError } from "../../errors/workflow-error.js";
|
||||
import {
|
||||
createRetryWrapper,
|
||||
RetryPresets,
|
||||
resolveRetryConfig,
|
||||
withRetry,
|
||||
} from "./retry.js";
|
||||
|
||||
describe("resolveRetryConfig", () => {
|
||||
it("fills defaults", { timeout: 5000 }, () => {
|
||||
const cfg = resolveRetryConfig();
|
||||
expect(cfg.maxAttempts).toBe(3);
|
||||
expect(cfg.strategy).toBe("fixed");
|
||||
expect(cfg.delayMs).toBe(1000);
|
||||
expect(cfg.maxDelayMs).toBe(30000);
|
||||
expect(cfg.backoffMultiplier).toBe(2);
|
||||
});
|
||||
|
||||
it("respects overrides", { timeout: 5000 }, () => {
|
||||
const cfg = resolveRetryConfig({
|
||||
maxAttempts: 5,
|
||||
strategy: "linear",
|
||||
delayMs: 50,
|
||||
maxDelayMs: 500,
|
||||
backoffMultiplier: 3,
|
||||
});
|
||||
expect(cfg).toEqual({
|
||||
maxAttempts: 5,
|
||||
strategy: "linear",
|
||||
delayMs: 50,
|
||||
maxDelayMs: 500,
|
||||
backoffMultiplier: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("withRetry", () => {
|
||||
it("returns on first success", { timeout: 5000 }, async () => {
|
||||
let calls = 0;
|
||||
const result = await withRetry(() => {
|
||||
calls++;
|
||||
return 42;
|
||||
});
|
||||
expect(result).toBe(42);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("exhausts attempts and rethrows the final error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let calls = 0;
|
||||
try {
|
||||
await withRetry(
|
||||
() => {
|
||||
calls++;
|
||||
throw new Error(`fail-${calls}`);
|
||||
},
|
||||
{ maxAttempts: 3, delayMs: 1 }
|
||||
);
|
||||
throw new Error("should not reach");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(WorkflowError);
|
||||
expect((e as WorkflowError).code).toBe(
|
||||
WORKFLOW_CODES.RETRY_EXHAUSTED
|
||||
);
|
||||
expect((e as WorkflowError).message).toBe("fail-3");
|
||||
}
|
||||
expect(calls).toBe(3);
|
||||
});
|
||||
|
||||
it("retry-exhausted with no thrown error path", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Edge: zero maxAttempts is invalid input but guards against infinite
|
||||
// loops if a future caller passes 0 — the loop body is skipped and we
|
||||
// surface the default message.
|
||||
try {
|
||||
await withRetry(() => 1, { maxAttempts: 0 });
|
||||
throw new Error("should not reach");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(WorkflowError);
|
||||
expect((e as WorkflowError).message).toBe(
|
||||
"Retry attempts exhausted"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("short-circuits when shouldRetry returns false", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let calls = 0;
|
||||
try {
|
||||
await withRetry(
|
||||
() => {
|
||||
calls++;
|
||||
throw new Error("nope");
|
||||
},
|
||||
{
|
||||
maxAttempts: 5,
|
||||
delayMs: 1,
|
||||
shouldRetry: () => false,
|
||||
}
|
||||
);
|
||||
throw new Error("should not reach");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(WorkflowError);
|
||||
expect((e as WorkflowError).message).toBe("nope");
|
||||
}
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("invokes onRetry between attempts", { timeout: 5000 }, async () => {
|
||||
const attempts: number[] = [];
|
||||
let calls = 0;
|
||||
await expect(
|
||||
withRetry(
|
||||
() => {
|
||||
calls++;
|
||||
throw new Error("again");
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
delayMs: 1,
|
||||
onRetry: (_e, a) => attempts.push(a),
|
||||
}
|
||||
)
|
||||
).rejects.toThrow(WorkflowError);
|
||||
expect(attempts).toEqual([1, 2]);
|
||||
expect(calls).toBe(3);
|
||||
});
|
||||
|
||||
it("wraps non-Error throwables", { timeout: 5000 }, async () => {
|
||||
try {
|
||||
await withRetry(
|
||||
() => {
|
||||
const v: unknown = "literal";
|
||||
throw v;
|
||||
},
|
||||
{ maxAttempts: 1 }
|
||||
);
|
||||
throw new Error("should not reach");
|
||||
} catch (e) {
|
||||
expect((e as WorkflowError).message).toBe("literal");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRetryWrapper", () => {
|
||||
it("wraps any function with the same options", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const wrap = createRetryWrapper({ maxAttempts: 2, delayMs: 1 });
|
||||
let calls = 0;
|
||||
await expect(
|
||||
wrap(() => {
|
||||
calls++;
|
||||
throw new Error("x");
|
||||
})
|
||||
).rejects.toThrow(WorkflowError);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RetryPresets", () => {
|
||||
it("returns fresh objects each call", { timeout: 5000 }, () => {
|
||||
const a = RetryPresets.standard();
|
||||
const b = RetryPresets.standard();
|
||||
expect(a).not.toBe(b);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it("none has 1 attempt", { timeout: 5000 }, () => {
|
||||
expect(RetryPresets.none().maxAttempts).toBe(1);
|
||||
});
|
||||
|
||||
it("quick has 3 fixed attempts", { timeout: 5000 }, () => {
|
||||
const q = RetryPresets.quick();
|
||||
expect(q.maxAttempts).toBe(3);
|
||||
expect(q.strategy).toBe("fixed");
|
||||
});
|
||||
|
||||
it("aggressive has 5 exponential attempts", { timeout: 5000 }, () => {
|
||||
const a = RetryPresets.aggressive();
|
||||
expect(a.maxAttempts).toBe(5);
|
||||
expect(a.strategy).toBe("exponential");
|
||||
});
|
||||
});
|
||||
105
packages/foundation/flow/workflow/src/middleware/retry/retry.ts
Normal file
105
packages/foundation/flow/workflow/src/middleware/retry/retry.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
DEFAULT_BACKOFF_MULTIPLIER,
|
||||
DEFAULT_DELAY_MS,
|
||||
DEFAULT_MAX_ATTEMPTS,
|
||||
DEFAULT_MAX_DELAY_MS,
|
||||
} from "../../constants/index.js";
|
||||
import { WORKFLOW_CODES, WorkflowError } from "../../errors/workflow-error.js";
|
||||
import type { RetryConfig, RetryStrategy } from "../../types/common.js";
|
||||
import { calculateRetryDelay, delay } from "../../utils/utils.js";
|
||||
|
||||
/** Options for the retry executor. */
|
||||
export interface RetryOptions extends Partial<RetryConfig> {
|
||||
readonly shouldRetry?: (error: Error, attempt: number) => boolean;
|
||||
readonly onRetry?: (error: Error, attempt: number) => void;
|
||||
}
|
||||
|
||||
/** Creates a full RetryConfig from partial options. */
|
||||
export function resolveRetryConfig(
|
||||
options?: Partial<RetryConfig>
|
||||
): RetryConfig {
|
||||
return {
|
||||
maxAttempts: options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
|
||||
strategy: options?.strategy ?? "fixed",
|
||||
delayMs: options?.delayMs ?? DEFAULT_DELAY_MS,
|
||||
maxDelayMs: options?.maxDelayMs ?? DEFAULT_MAX_DELAY_MS,
|
||||
backoffMultiplier:
|
||||
options?.backoffMultiplier ?? DEFAULT_BACKOFF_MULTIPLIER,
|
||||
};
|
||||
}
|
||||
|
||||
/** Executes a function with retry logic. */
|
||||
export async function withRetry<T>(
|
||||
fn: () => T | Promise<T>,
|
||||
options?: RetryOptions
|
||||
): Promise<T> {
|
||||
const config = resolveRetryConfig(options);
|
||||
const shouldRetry = options?.shouldRetry ?? (() => true);
|
||||
const onRetry = options?.onRetry;
|
||||
|
||||
let lastError: Error | undefined;
|
||||
for (let attempt = 1; attempt <= config.maxAttempts; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
if (attempt >= config.maxAttempts) break;
|
||||
if (!shouldRetry(lastError, attempt)) break;
|
||||
|
||||
onRetry?.(lastError, attempt);
|
||||
|
||||
const retryDelay = calculateRetryDelay(
|
||||
attempt,
|
||||
config.strategy,
|
||||
config.delayMs,
|
||||
config.backoffMultiplier,
|
||||
config.maxDelayMs
|
||||
);
|
||||
await delay(retryDelay);
|
||||
}
|
||||
}
|
||||
// All attempts exhausted — wrap into a typed WorkflowError so callers can
|
||||
// pattern-match on the stable code while still seeing the underlying cause.
|
||||
throw new WorkflowError(
|
||||
WORKFLOW_CODES.RETRY_EXHAUSTED,
|
||||
lastError?.message ?? "Retry attempts exhausted",
|
||||
lastError ? { cause: lastError } : undefined
|
||||
);
|
||||
}
|
||||
|
||||
/** Creates a retry wrapper function that can be reused. */
|
||||
export function createRetryWrapper(
|
||||
options?: RetryOptions
|
||||
): <T>(fn: () => T | Promise<T>) => Promise<T> {
|
||||
return <T>(fn: () => T | Promise<T>) => withRetry(fn, options);
|
||||
}
|
||||
|
||||
/** Predefined retry configurations. */
|
||||
export const RetryPresets = {
|
||||
none: (): RetryConfig => ({
|
||||
maxAttempts: 1,
|
||||
strategy: "fixed" as RetryStrategy,
|
||||
delayMs: 0,
|
||||
}),
|
||||
quick: (): RetryConfig => ({
|
||||
maxAttempts: 3,
|
||||
strategy: "fixed" as RetryStrategy,
|
||||
delayMs: 100,
|
||||
}),
|
||||
standard: (): RetryConfig => ({
|
||||
maxAttempts: 3,
|
||||
strategy: "exponential" as RetryStrategy,
|
||||
delayMs: 1000,
|
||||
backoffMultiplier: 2,
|
||||
maxDelayMs: 10000,
|
||||
}),
|
||||
aggressive: (): RetryConfig => ({
|
||||
maxAttempts: 5,
|
||||
strategy: "exponential" as RetryStrategy,
|
||||
delayMs: 500,
|
||||
backoffMultiplier: 2,
|
||||
maxDelayMs: 30000,
|
||||
}),
|
||||
} as const;
|
||||
Reference in New Issue
Block a user