@nodii/idempotency
v0.10.0
Published
Idempotency library for the Nodii microservice stack — wrapForSagaStep + wrapForWebhook + wrapConsumerWorker + computeIdempotencyKey + ioredis CLAIM via Lua + sweeper + gRPC idempotencyGuard interceptor. Polyglot ship: TS + Python + Go in parity. Spec: pl
Readme
@nodii/idempotency
Spec: https://planning.dev.nucleus-cloud.in/api/v1/feature-docs?serviceId=nodii-libs&docKey=idempotency
Entry points
| Import | What it gives you |
|---|---|
| @nodii/idempotency | initIdempotency, the three wrappers (wrapForSagaStep / wrapForWebhook / wrapConsumerWorker), key helpers, the D242 outcome classifier, the sweeper |
| @nodii/idempotency/grpc | idempotencyGuard — the server-side unary interceptor |
| @nodii/idempotency/hono | idempotencyHono — the HTTP middleware (below) |
| @nodii/idempotency/lease | SingleActiveLease (below) |
| @nodii/idempotency/durable | Postgres consumer-dedup (below) |
| @nodii/idempotency/test-doubles | InMemoryRedisClient / FailingRedisClient |
Boot
await initIdempotency({ redis, serviceId: "billing" });That is the whole bootstrap. Since 0.10.0 it also arms the in-flight
sweeper, so a handler that dies between CLAIM and COMPLETE no longer wedges
its key for the full TTL — every retry used to 409 for 24h. Opt out with
sweeper: false; tune with sweeper: { intervalMs, batchSize, … }. Call
shutdownIdempotency() on SIGTERM to stop it and release its lease.
Two things about the sweep loop are worth knowing. Each process picks its own
cadence within ±20% of intervalMs (intervalJitterRatio, set 0 for an
exact interval) — thirteen services on one shared Redis must not all SCAN on the
same 5-minute boundary. And the SCAN cursor persists across passes: a pass
that hits maxBatchesPerPass stops, fires onTruncated and resumes from where
it stopped. It used to restart at 0 every pass, so once the keyspace outgrew
the batch cap the tail was never swept at all — silently, forever. Note that
SCAN's COUNT is a hint over the whole Redis DB, not over keys matching the
pattern, so on a shared instance that cap is nearer than it looks.
Also since 0.10.0: a throw carrying no numeric gRPC code classifies as
transport (the key is RELEASED, the retry re-executes) rather than
application (cached rejection). The D242 status table for CODED errors is
unchanged. Pass defaultClass: "application" to restore the old fall-through.
HTTP idempotency (@nodii/idempotency/hono)
import { idempotencyHono } from "@nodii/idempotency/hono";
app.use("*", idempotencyHono());Mount it BELOW your auth middleware. It needs the trusted, post-auth tenant and actor and it fails the request closed without them.
A mutating request (POST/PUT/PATCH/DELETE) carrying Idempotency-Key (or the
doctrine header x-nodii-idempotency-key) is claimed under a length-framed
digest of (tenant, actor, "<METHOD> <path>", key):
| Situation | Result |
|---|---|
| Same key, same body, same actor | The first response replayed verbatim, handler NOT re-run, Idempotency-Replayed: true |
| Same key, different body | 422 idempotency_key_body_mismatch (D442) |
| Concurrent same key | 409 idempotency_in_flight |
| Handler returned 2xx | Cached and replayed |
| Any other status, or a throw | Claim RELEASED; the retry re-executes |
| Response streaming / too big / too slow to drain | Delivered normally, NOT cached, claim released |
| Malformed key | 400 idempotency_key_invalid |
| No key, or a read verb | Passes through un-cached — the header is client-opt-in |
| No resolvable tenant | 400 idempotency_tenant_required — fails closed, never a shared bucket |
| No resolvable actor | 400 idempotency_actor_required — see below |
The tenant comes from c.var.tenantId and the actor from the first of
actorId / userId / subjectId / sub / membershipId / principalId;
supply resolveTenantId / resolveActorId for surfaces with non-JWT auth
(webhook ingress, edge-stamped principals). hono is an optional peer
dependency — the middleware types structurally and imports no hono.
Why the bucket is actor-scoped, and why only 2xx is cached
A replay never calls next(). That is the point of a replay, but it means
everything below the mount is skipped on a hit — including route-level
permission middleware. Bucketing on (tenant, route, key) alone would therefore
hand one user a response another user was authorized for. Scoping to the actor
makes a replay serviceable only to the principal who already passed that route's
authorization when the entry was written.
The residual, stated plainly: authorization is evaluated once, when the entry is
written, and a replay inside the TTL rides on that evaluation. A permission
revoked mid-TTL does not invalidate the actor's own cached response. That is
bounded by the TTL and leaks nothing the actor did not already hold.
bucketScope: "tenant" opts out — explicitly, in code — for surfaces with no
per-user principal, and then the cache is tenant-wide: any caller in the
tenant presenting the same key on the same route gets the cached response
without the route's own authorization running. Only set it where every caller
that can reach the mount is equally privileged.
The digest is length-framed. It must stay that way: the last component is
the caller's raw header, and under plain concatenation the caller owns the split
point — sha256(t ‖ "POST /a" ‖ "dmin/refundsvk") equals
sha256(t ‖ "POST /admin/refunds" ‖ "vk"), so for any route pair where one path
prefixes the other there is a key that makes the digests collide. Never use
computeIdempotencyKey (unframed, the D163 parity contract) for a
request-scoped digest.
Only 2xx is cached. "Release when the handler throws" cannot work on Hono:
compose catches the throw at its own depth and returns onError's response,
and every nodii service ships a central error mapper — so a DB deadlock arrives
here as a returned 409 and a rate-limit as a returned 429. Caching those
pins the operation for the whole 24h TTL, which is the exact failure this
library exists to prevent, relocated from 5xx to 4xx. Not caching a genuinely
deterministic 4xx costs one re-execution that fails the same way. A service that
wants a specific 4xx of its own replayed says so at the mount:
cacheableStatus: (s) => (s >= 200 && s < 300) || s === 422.
The cost, stated plainly: "one re-execution that fails the same way" assumes
the handler never reached its side effect. A handler that does mutate and then
returns 4xx — charge the card, fail the ledger write, map to 409 — is now
re-executed where it previously replayed. For that handler this trades an
unrecoverable pin for a possible double effect, and the double effect is worse.
Neither rule is safe there, and the old 5xx rule had the same hole. Fix it on
the handler (make the mutation internally idempotent, or stop signalling a
post-mutation failure with a status that means "nothing happened"); until then,
opt that one status back in via cacheableStatus and accept the pin. Do not
widen to "all 4xx".
Body and response limits
The D442 body fingerprint reads a CLONE of the raw Request, so a downstream
c.req.json() / c.req.parseBody() behaves exactly as it would with the
middleware unmounted — JSON, multipart/form-data and x-www-form-urlencoded
routes are all safe to mount behind app.use("*", …). Under
maxHashedRequestBytes (1 MiB) the body is buffered once and hashed
canonically; over it the fingerprint switches to a streaming raw-byte hash
that retains nothing, so a large upload is never made resident twice. Pass
hashBody: false to skip the read entirely.
Responses are drained for caching under both a byte cap
(maxCachedResponseBytes, 256 KiB) and a time cap (cacheDrainTimeoutMs, 5s),
and text/event-stream / application/x-ndjson skip the drain outright. A
response that exceeds either is delivered normally and simply not cached — a
streaming endpoint behind the mount no longer hangs forever.
Single-active lease (@nodii/idempotency/lease)
import { SingleActiveLease } from "@nodii/idempotency/lease";Re-exported from @nodii/telemetry/worker, where the D402 primitive lives —
one implementation, two discoverable names. See that package for the full
lifecycle (acquire NX, renew at ttl/2, fence token, release() in a finally)
and for sizing ttlMs by crash-detection latency, not job runtime.
This is a subpath, not part of the root barrel, and that is deliberate. A
root re-export makes every consumer of @nodii/idempotency depend at ESM link
time on the exact export set of whatever @nodii/telemetry resolves. A peer
floor that is even slightly wrong then becomes an uncatchable SyntaxError on
boot in all thirteen services at once — and nothing before boot catches it
(bun install warns and exits 0; tsc passes under skipLibCheck). Behind a
subpath the blast radius is the one service that imported it. The surface here
is pinned to what the >=0.26.0 peer floor can actually satisfy, asserted by
tests/peer-floor.test.ts.
The peer floor moves from >=0.14.0 to >=0.26.0 in this release.
Everything this package imports from @nodii/telemetry/worker is present in
the published 0.26.0 tarball — measured with npm pack rather than assumed.
withSingleActiveLease (the renewal + fence scope function) is not exported
here yet, and under this floor it must not be: the published 0.26.0
dist/worker/ does not contain it (0.27.0 is the first version that does).
Adding the re-export is a separate, reviewed change that raises the floor to
>=0.27.0 in the same commit.
Durable consumer-dedup (@nodii/idempotency/durable)
Comm-doctrine § 7.4 Pattern 3 + § 9.2: an inbound-EVENT consumer dedups on the
domain event_id against a Postgres dedup table (a row, not a Redis key) so
the dedup survives a Redis flush. This lets Pattern-3 consumers drop
hand-rolled durable ledgers (PROV-SUB-IDEM).
It writes the canonical shared consumed_events table per D198 (LOCKED
2026-06-03) — the single cross-lib consumer-dedup ledger, keyed on the composite
PK (event_id, topic). @nodii/approval + @nodii/replica-consumer already
create/share this exact-shape table; the DDL here is byte-compatible (same
columns, same PK, same ON CONFLICT (event_id, topic) DO NOTHING), so all three
coexist via CREATE TABLE IF NOT EXISTS — whichever migrates first wins and they
share the row. The composite key is the cross-topic-correctness key (the same
event_id can recur across topics); topic is the subscriber-discriminator (no
per-subscriber column, per D198).
import postgres from "postgres";
import {
applyConsumerDedupMigration,
claimEvent,
wrapConsumerWorkerDurable,
sweepConsumerDedup,
} from "@nodii/idempotency/durable";
const sql = postgres(process.env.DATABASE_URL!);
await applyConsumerDedupMigration(sql); // idempotent; boot-time
// Atomic claim — { claimed:true } first time, { claimed:false } duplicate.
const { claimed } = await claimEvent(sql, { eventId: e.event_id, topic: e.topic });
if (claimed) await process(e);
// Or wrap the whole consume lifecycle (claim → run once / skip duplicate):
await wrapConsumerWorkerDurable(sql, {
eventId: e.event_id,
topic: e.topic,
handler: () => process(e),
});
// Daily retention sweep (default 30 days; run in-service per § 9.3).
await sweepConsumerDedup(sql, 30);postgres (porsager) is an optional peer dependency — only durable-dedup
users need it. Pass a sql.begin(tx => …) handle to claimEvent / tx to
claim atomically with the consumer's business-state write (§ 7.4 same-tx).
Ships in TS / Python (nodii_idempotency.durable, asyncpg optional extra
durable) / Go (durable.go, NewDurablePgxExecutor) in parity.
