Files
skills/docs/api-stack/api/api-saga/src/idempotency/idempotency.md
2026-05-29 08:20:56 +01:00

3.6 KiB

idempotency

(no submodules)

Purpose

Per design doc §9.1 the saga orchestrator MUST wrap every step invocation in an idempotency guard keyed by {sagaId, stepName} so a replayed step (e.g. after a host restart or a transient network-induced retry) returns the cached outcome without re-entering the handler. This module is the thin wrapper layered over the foundation @suaveplan/idempotency package so the orchestrator does not have to know the underlying guard's API.

The wrapper also honours the per-action idempotency: "manual" opt-out — the participant registration can declare that the action handles its own dedup, in which case the wrapper invokes the handler directly and the foundation guard is bypassed. This matches the "Idempotency contract" bullet under design §9.1.

Features

  • stepIdempotencyKey(sagaId, stepName) — stable composite key shared between orchestrator and recovery so a replayed step keeps hashing to the same record.
  • SagaIdempotencyWrapper.run(...) — single entry point the orchestrator funnels every step invocation through.
  • Honours the per-action idempotency: "manual" opt-out: the wrapper short-circuits before the guard runs.
  • Composable with any IdempotencyGuard shape — the foundation package ships memory, database, and Redis store adapters; the saga wrapper picks whichever one the host has wired.

Usage: Basic

import { IdempotencyGuard, MemoryIdempotencyStore } from "@suaveplan/idempotency";
import { SagaIdempotencyWrapper } from "@suaveplan/api-saga/idempotency/idempotency";

const guard = new IdempotencyGuard({ store: new MemoryIdempotencyStore() });
const wrapper = new SagaIdempotencyWrapper(guard);

const output = await wrapper.run("s_1", "charge", "auto", async () => {
    return { chargeId: "ch_1" };
});

Usage: Advanced

Compose the wrapper with the orchestrator step-dispatch path. The wrapper is intentionally generic over the result type so a typed step handler keeps its return type through the wrap:

import { SagaIdempotencyWrapper } from "@suaveplan/api-saga";

async function dispatchStep<T extends Readonly<Record<string, unknown>>>(
    sagaId: string,
    step: StepDeclaration,
    wrapper: SagaIdempotencyWrapper,
    handler: () => Promise<T>
): Promise<T> {
    return wrapper.run(sagaId, step.action, registration.idempotency, handler);
}

A retried step whose first attempt completed under the same {sagaId, stepName} key returns the cached outcome — the handler body is not re-entered. A retried step whose previous attempt is still pending (lock not timed out) throws IdempotencyConflictError; the orchestrator interprets this as "let the live attempt finish" and does not re-fire the step.

API Reference

  • stepIdempotencyKey(sagaId, stepName): string — saga:<sagaId>:<stepName>.
  • SagaIdempotencyWrapper(guard: IdempotencyGuard) — constructor.
  • wrapper.run<T>(sagaId, stepName, mode, fn): Promise<T> — returns the cached or newly produced outcome.

Implementation Notes

The wrapper is intentionally NOT a subclass of IdempotencyGuard — it composes one. The foundation guard owns fingerprinting and TTL; the saga wrapper only needs the execute(key, fn) entry point. Subclassing would expose internal state through protected slots and turn this module into a tight binding to the guard's class hierarchy.

Manual mode is deliberately a no-op rather than a "bypass + warn" — some saga steps (e.g. external API calls whose dedup is already handled by the API's request-id contract) genuinely don't want a second layer of idempotency.