Idempotency
@modularityjs/http-idempotency makes mutating routes replay-safe: a retried request carrying the same Idempotency-Key header gets the first request's stored response instead of executing the handler again. Built entirely on the cache and lock primitives — with Redis drivers, idempotency is cross-instance.
@Idempotent
import { Idempotent } from '@modularityjs/http-idempotency';
@Controller('/payments')
class PaymentsController {
@Idempotent({ ttlMs: 60_000 })
@Post()
async charge(@ValidatedBody(chargeSchema) input: ChargeInput) {
return this.payments.charge(input); // runs at most once per key
}
}The client generates a key (typically a UUID) per logical operation and resends it on retry:
POST /payments
Idempotency-Key: 6f1c9f36-6ac0-4b2b-9d3e-6d1d54c07c55Semantics, in order:
- First request — the handler runs; a 2xx result is stored (status code + JSON body) for
ttlMs. - Replay — a later request with the same key gets the stored status and body without invoking the handler, plus an
X-Idempotent-Replay: trueheader. - Concurrent duplicate — while the first request is still in flight (the key's lock is held), a duplicate gets a 409
ConflictException("A request with this Idempotency-Key is already in progress."). The client retries after the first completes. - Failures stay retryable — only 2xx responses are stored. A 4xx/5xx outcome is never cached, so a retry with the same key re-executes the handler instead of replaying the failure for the whole
ttlMs. - Reused key, different payload — a 422
ValidationException. The stored response is fingerprinted against the request body it came from, so a client bug that recycles a key across two different operations gets an error rather than the wrong operation's response. - No key — the request passes through un-idempotent, unless
required: true, in which case a missing header is a 422ValidationException.
Options:
| Option | Default | Purpose |
|---|---|---|
ttlMs | — (required) | Replay window; validated at decoration time |
keyPrefix | 'idempotency' | Cache/lock key namespace |
required | false | Reject requests without an Idempotency-Key header with 422 |
lockTtlMs | 60_000 | In-flight lock lease — sized to the handler, not the retry horizon |
scope | auth id, else IP | Derives the caller-identity segment of the key |
The storage key is keyPrefix:scope:METHOD:url:key. Streaming and special return types (FileResponse, HtmlResponse, SseResponse, RedirectResponse) pass through uncached. Headers a handler sets imperatively through an injected @Response() — a Location on 201, say — are not restored on replay; declare them with @Header instead, which the adapter re-applies per response.
Keys are client-chosen, so they are scoped per caller
An Idempotency-Key is whatever the client sends. If the key alone identified the entry, any caller who guessed or reused another's key on the same route would receive that caller's stored response body. scope closes this: it defaults to the authenticated identity's id (request.auth, set by http-auth) and falls back to the client IP on unauthenticated routes, so the default fails closed. Override it to key off a tenant, API key, or session:
@Post('/payments')
@Idempotent({ ttlMs: 86_400_000, scope: (request) => `tenant:${request.headers['x-tenant-id']}` })
pay(@Body() body: PaymentInput) {}Returning undefined from scope puts the request in one shared bucket — correct only for routes whose responses carry nothing caller-specific.
The IP fallback relies on
request.ip. Behind a load balancer that is the proxy's address unlessHttpModuleConfig.trustProxyis enabled — without it, unauthenticated callers share one IP-scoped namespace.
Wiring
// modules: [
// ModularityModule,
// HttpModule.forRoot({ port: 3000 }),
// HttpFastifyModule,
// CacheModule,
// CacheMemoryModule, // or CacheRedisModule — replay becomes cross-instance
// LockModule,
// LockMemoryModule, // or LockRedisModule — 409 detection becomes cross-instance
// HttpIdempotencyModule,
// ...
// ]Like @SerializeWith and @CacheResponse, the decorator is a synthetic per-site interceptor on the UseInterceptor channel — zero adapter changes, portable to any HTTP driver. Using @Idempotent without HttpIdempotencyModule wired fails at the first decorated request with a StateException whose message starts with MJS0016 and names the fix (see Boot Errors).
How the race is closed
On a cache miss the interceptor acquires a per-key lock, then re-checks the cache under the lock before executing — a duplicate that lost the acquire race but arrived after the first request finished still replays instead of re-executing. The lock is always released in finally; if the lock backend fails on release after the handler already committed its side effects, the failure is warned (ModularityJsHttpIdempotencyReleaseError) rather than thrown, because turning a succeeded mutation into a 500 would invite the client to retry it.
The lease is lockTtlMs (60s by default), deliberately separate from ttlMs. Sizing the lease to a 24h replay window would mean a holder that dies mid-handler — a deploy, an OOM — wedges that key with 409s for 24 hours. Raise lockTtlMs above your slowest handler, never to your retry horizon.
See the Idempotency wiring recipe for a complete bootable example.