tenant-idempotency
v0.1.0
Published
Tenant-scoped idempotency keys for Node.js — exactly-once request processing with pluggable storage (in-memory, Redis, Postgres) and zero runtime dependencies.
Downloads
160
Maintainers
Readme
tenant-idempotency
Stop double-charging your users. Tenant-scoped idempotency keys for Node.js — retried or duplicated requests (payments, invoices, webhooks) execute exactly once, and duplicates get the original response back. Zero runtime dependencies.
npm install tenant-idempotencyQuickstart
import { createIdempotencyStore } from 'tenant-idempotency';
const store = createIdempotencyStore(); // in-memory by default
// The handler runs exactly once per (scope, key) — duplicates get the cached value.
const { value, cached } = await store.execute(
{ key: request.headers['idempotency-key'], scope: tenantId },
async () => chargeCustomer({ amount: 4200 }),
);Or drop it in front of your Express mutation routes:
import express from 'express';
import { createIdempotencyStore, httpIdempotency } from 'tenant-idempotency';
const app = express();
const store = createIdempotencyStore();
app.post(
'/payments',
httpIdempotency(store, { scope: (req) => req.auth.tenantId }),
async (req, res) => {
const payment = await processPayment(req.body); // runs once per key
res.status(201).json(payment);
},
);Clients send a UUID in the Idempotency-Key header. Retrying the same request replays the original 201 + body (with an Idempotency-Replayed: true header); firing it twice concurrently gets a 409 on the duplicate.
How it works
Every operation is identified by a client-generated key, isolated per scope (your tenant ID — the same key in two tenants is two independent operations). Processing walks a tiny state machine:
reserve (atomic)
┌────────────┼─────────────────┐
free in-flight completed
│ │ │
run reject 409 replay cached
handler (client retries) response
│
├─ success → complete: cache result for ttlMs
└─ failure → release: key is retryable againThree details make this safe in production:
- Atomic reservation. When N requests race on the same key, the storage adapter guarantees exactly one wins (
SET NXin Redis,INSERT ... ON CONFLICTin Postgres, synchronous check-and-set in memory). - Crash recovery. A reservation only blocks duplicates for
inFlightTtlMs. If the process dies mid-request, the key unlocks automatically and a retry takes over. - Token fencing.
reserveissues an ownership token;complete/releaseare no-ops without it. A slow, presumed-dead request that wakes up after its key was taken over can't clobber the new owner's result.
Failed handlers are not cached — only successful results are, so clients can safely retry errors.
Storage adapters
| Adapter | Use when | Setup |
|---|---|---|
| MemoryAdapter (default) | Tests, dev, single process | none |
| RedisAdapter | Multi-process, you have Redis | pass an ioredis-compatible client |
| PostgresAdapter | Multi-process, you have Postgres | run PostgresAdapter.schemaSql(), pass a pg Pool |
// Redis (ioredis)
import Redis from 'ioredis';
import { createIdempotencyStore, RedisAdapter } from 'tenant-idempotency';
const store = createIdempotencyStore({ adapter: new RedisAdapter(new Redis(url)) });// Postgres (pg)
import { Pool } from 'pg';
import { createIdempotencyStore, PostgresAdapter } from 'tenant-idempotency';
const pool = new Pool({ connectionString });
await pool.query(PostgresAdapter.schemaSql()); // or ship it as a migration
const store = createIdempotencyStore({ adapter: new PostgresAdapter(pool) });
// Postgres has no native TTL — purge expired rows periodically:
setInterval(() => store.cleanup(), 60 * 60 * 1000);Neither adapter imports its driver — you pass any client matching a minimal structural interface (RedisLikeClient / PostgresLikeClient), so the library has zero runtime dependencies. Custom backend? Implement the four-method StorageAdapter interface.
API reference
createIdempotencyStore(options?) → IdempotencyStore
| Option | Type | Default | |
|---|---|---|---|
| adapter | StorageAdapter | new MemoryAdapter() | storage backend |
| ttlMs | number | 24 h | how long completed results are replayed |
| inFlightTtlMs | number | 60 s | how long an unfinished reservation blocks duplicates; set above your slowest handler |
store.execute(ref, handler, metadata?) → Promise<{ value, cached, metadata? }>
Runs handler exactly once per ref ({ key, scope? }).
- First call → runs the handler, caches the resolved value, returns
{ value, cached: false }. - Duplicate after completion →
{ value, cached: true, metadata }without running the handler. - Duplicate while in flight → throws
IdempotencyInFlightError(code: 'IDEMPOTENCY_IN_FLIGHT') — map it to HTTP 409. - Handler throws → key is released (retryable) and the error propagates.
Values must be JSON-serializable; cached values come back as plain JSON. Optional metadata (e.g. { statusCode: 201 }) is stored and returned on replays.
store.reserve(ref) / store.complete(ref, token, value, metadata?) / store.release(ref, token)
Low-level primitives for when the result isn't known inside a callback (the HTTP middleware uses these). reserve returns { outcome: 'reserved', token } | { outcome: 'in_flight' } | { outcome: 'completed', value, metadata }. After reserving you must complete or release with the token.
store.cleanup() → Promise<number>
Deletes expired records (needed for Postgres/memory; Redis expires natively).
httpIdempotency(store, options?)
Express-style middleware. Options: header (default 'idempotency-key'), scope: (req) => string (derive the tenant), required (default true; false lets keyless requests pass through un-deduplicated). Behavior: missing header → 400 · replay → original status + body + Idempotency-Replayed: true · concurrent duplicate → 409 · non-2xx responses are not cached.
StorageAdapter
interface StorageAdapter {
reserve(storageKey: string, inFlightExpiresAt: Date): Promise<ReserveResult>; // must be atomic
complete(storageKey: string, token: string, value: unknown,
metadata: ResultMetadata | undefined, expiresAt: Date): Promise<boolean>;
release(storageKey: string, token: string): Promise<boolean>;
cleanup?(): Promise<number>;
}complete/release must be no-ops (returning false) when token no longer owns the key.
License
MIT
