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/coreContract 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:
| Scope | Default 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
| Reservation | Response |
|---|---|
reserved | Handler runs; 2xx result is stored for replay |
replay | Stored response + idempotency-replayed: true |
inProgress | 409 with code IDEMPOTENCY_IN_PROGRESS |
conflict | 409 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.