Idempotency

Idempotency makes retryable work safe. Use it when the same logical command may arrive more than once: a user double-submits a form, a mobile client retries a request, a webhook provider redelivers an event, or a job provider retries background work.

Beignet enforces idempotency at the HTTP boundary the same way it enforces rate limits: contracts declare the requirement, createIdempotencyHooks(...) enforces it, and IdempotencyPort decides where reservations live. runIdempotently(...) remains the workflow-level primitive for non-HTTP work.

bun add @beignet/core

Contract metadata

Declare the requirement on the contract:

export const createAppointment = appointments
  .post("/")
  .headers(
    z.object({
      "idempotency-key": z.string().min(1),
    }),
  )
  .body(CreateAppointmentRequest)
  .meta({
    idempotency: {
      required: true,
      header: "idempotency-key",
      scope: "actor-tenant",
      ttlSec: 60 * 60 * 24,
    },
  })
  .errors({
    IdempotencyConflict: errors.IdempotencyConflict,
    IdempotencyInProgress: errors.IdempotencyInProgress,
  })
  .responses({ 201: AppointmentResponse });

The optional headers schema documents the key for OpenAPI and typed clients and rejects requests without it during request validation. The .errors(...) declarations reuse Beignet's httpErrors.IdempotencyConflict and httpErrors.IdempotencyInProgress catalog entries so clients see the declared 409 responses.

Typed clients send keys automatically

Beignet clients read the same contract metadata. When a contract declares idempotency, every call attaches a generated UUID to the metadata header (meta.header, default idempotency-key), so components never build keys by hand:

const createAppointmentEndpoint = apiClient.endpoint(createAppointment);

// The client generates and attaches the idempotency-key header.
await createAppointmentEndpoint.call({ body });

The generated key is injected before request header validation runs, and the header becomes optional in the call types. To take control of the key, pass idempotencyKey as a call option for retry-with-same-key flows, or pass the header explicitly in headers — an explicit header always wins over generation.

Automatic keys prevent retry storms: HTTP retries of one logical call reuse one key, so the server executes the command once. They do not deduplicate separate calls — a double-click that triggers two call(...) or mutate(...) invocations sends two keys and runs the command twice. Guard double-submits in the UI, or pass the same explicit idempotencyKey for both attempts. required: true on the contract remains the server-side backstop: clients that are not Beignet clients — curl, mobile apps, third-party integrations — still get a framework-owned 400 when the key is missing.

React Query mutations keep the generated key stable across TanStack retry attempts. See React Query for the details.

Hook wiring

Install the built-in hook where the server is composed:

import { createIdempotencyHooks } from "@beignet/core/server";

export const server = await createNextServer({
  ports: appPorts,
  hooks: [createIdempotencyHooks<AppContext>()],
  // ...
});

The hook reads contract.metadata.idempotency and enforces it with ctx.ports.idempotency. After request parsing it reserves { namespace: "http.<contract name>", key, scope, fingerprint }, where the fingerprint hashes the parsed { path, query, body }. A completed matching reservation short-circuits the handler and replays the stored response with an idempotency-replayed: true header. On success it stores 2xx responses for replay; on errors, non-2xx responses, and native Response streams it releases the reservation so a retry re-executes. Routes without idempotency metadata pass through untouched.

Scopes

meta.scope controls who may replay a stored result:

ScopeDefault scope value
global"global"
actor{ actorId: ctx.actor?.id }
tenant{ tenantId: ctx.tenant?.id }
actor-tenant{ actorId: ctx.actor?.id, tenantId: ctx.tenant?.id }

Scope keys to the boundary that owns the operation so one actor or tenant can never replay another's result.

Error semantics

ReservationResponse
reservedHandler runs; 2xx result is stored for replay
replayStored response + idempotency-replayed: true
inProgress409 with code IDEMPOTENCY_IN_PROGRESS
conflict409 with code IDEMPOTENCY_CONFLICT

The server maps the IdempotencyConflictError and IdempotencyInProgressError primitives to framework-owned 409 envelopes — including from use cases that call runIdempotently(...) directly, so apps do not need to re-map the primitive errors to their own catalog entries.

Customization

Override the namespace, scope, or fingerprint input when defaults do not fit:

createIdempotencyHooks<AppContext>({
  namespace: ({ contract }) => `api.${contract.name}`,
  scope: ({ ctx, meta }) => ({
    tenantId: ctx.tenant?.id,
    plan: ctx.tenant?.metadata?.plan,
  }),
  fingerprintInput: ({ body }) => body,
});

Use fingerprintInput when request metadata such as tracing headers or pagination noise should not define the logical command.

Workflow-level idempotency

Use runIdempotently(...) inside a use case, job, listener, webhook handler, or schedule when the retried work does not arrive over HTTP, or when the workflow itself owns retry safety:

import {
  createIdempotencyFingerprint,
  runIdempotently,
} from "@beignet/core/idempotency";

export const importAppointments = useCase
  .command("appointments.import")
  .input(ImportAppointmentsInput)
  .output(ImportResult)
  .run(async ({ ctx, input }) => {
    const fingerprint = await createIdempotencyFingerprint(input, {
      omit: ["importId"],
    });

    return runIdempotently(ctx.ports.idempotency, {
      namespace: "appointments.import",
      key: input.importId,
      scope: {
        tenantId: ctx.tenant?.id,
        actorId: ctx.actor?.id,
      },
      fingerprint,
      ttlSec: 60 * 60 * 24,
      run: () =>
        ctx.ports.uow.transaction((tx) =>
          tx.appointments.importBatch(input.rows),
        ),
    });
  });

The helper reserves the key before running the workflow, completes it with the returned result, and releases the reservation if the workflow throws. The HTTP hook uses an http.-prefixed namespace, so HTTP reservations never collide with use-case namespaces.

The three identities work together: the namespace separates unrelated operations that may receive the same key, the scope prevents one actor or tenant from replaying another's result, and the fingerprint detects when the same key is reused with different payload data. createIdempotencyFingerprint(input, { omit: [...] }) creates a stable SHA-256 digest from a canonical JSON representation, omitting the key itself and other request-only metadata, and does not store the original payload.

runIdempotently(...) resolves reservations the same way the hook does: replay returns the stored result by default, inProgress throws IdempotencyInProgressError, and conflict throws IdempotencyConflictError. Pass replay: "error" when an operation should reject duplicates instead of returning the stored result.

Port wiring

Add idempotency: IdempotencyPort from @beignet/core/idempotency to your AppPorts, and use the memory adapter only for tests, local examples, and single-process development:

// infra/app-ports.ts
import { createMemoryIdempotencyStore } from "@beignet/core/idempotency";

export const appPorts = definePorts<AppPorts>({
  idempotency: createMemoryIdempotencyStore(),
  // other ports...
});

Production apps should implement IdempotencyPort with a durable store such as SQL or Redis. A durable adapter stores one row per reserved key, and the storage operation behind reserve(...) must be atomic — provider packages ship this, so apps rarely write it. The standard Drizzle/libSQL path is createDrizzleSqliteIdempotencyPort(db) from @beignet/provider-db-drizzle/sqlite, with createDrizzleSqliteIdempotencySetupStatements() executed in your app-owned migration flow.

Unit-of-work-aware adapters

For high-integrity workflows, prefer a SQL adapter that participates in the same Unit of Work as the business write, so the reservation, domain write, audit entry, and completed idempotency result share one transaction and the database commit becomes the single durability boundary — if the workflow throws or the process crashes before commit, the reservation rolls back with everything else. The use case shape changes from "idempotency wraps a transaction" to "the transaction exposes an idempotency port":

await ctx.ports.uow.transaction((tx) =>
  runIdempotently(tx.idempotency, {
    namespace: "appointments.import",
    key: input.importId,
    scope: {
      tenantId: ctx.tenant?.id,
      actorId: ctx.actor?.id,
    },
    fingerprint,
    ttlSec: 60 * 60 * 24,
    run: async () => {
      const result = await tx.appointments.importBatch(input.rows);

      await tx.audit.record(/* ... */);
      await events.record(tx.events, appointmentsImported, {
        importId: input.importId,
      });

      return result;
    },
  }),
);

Infra creates the adapter from the transaction client next to the repositories; see Database and transactions for the createTransactionPorts wiring.

Idempotency prevents duplicate command execution; it does not replace durable message delivery. Use an outbox when post-commit event or job delivery must be durable, and see Workflow primitives when deciding whether a workflow needs idempotency, an outbox record, a job, a schedule, or a notification.

Jobs and webhooks

Jobs and webhooks should use keys from the system that retries them: a webhook handler keys on the provider event id (for example stripeEvent.id under a webhooks.stripe.* namespace), and a Beignet job keys on an app-owned job id or logical command id. Keep the idempotency check inside the job handler when the job itself owns the retried work — Jobs shows the full handler pattern. Payments and billing applies this to verified Stripe webhooks.

Testing

Use the memory store in tests:

const idempotency = createMemoryIdempotencyStore();

const first = await runIdempotently(idempotency, {
  namespace: "posts.create",
  key: "key_1",
  fingerprint: "fingerprint_1",
  run: async () => ({ id: "post_1" }),
});

const second = await runIdempotently(idempotency, {
  namespace: "posts.create",
  key: "key_1",
  fingerprint: "fingerprint_1",
  run: async () => ({ id: "post_2" }),
});

expect(second).toEqual(first);

HTTP-level behavior is testable through server.api(...): send the same request twice with one key and assert the second response carries idempotency-replayed: true, then change the body and assert the 409 IDEMPOTENCY_CONFLICT envelope. This makes duplicate-submit behavior testable without depending on a database or queue provider.