Idempotency Wiring
Recipes are working wiring examples with the sharp edges annotated. This page and the
idempotency-wiringrecipe served to coding agents by the MCP server share one source (@modularityjs/manifest), so they never drift apart.
Three contracts compose (the extension is pure composition — no backend of its own):
@modularityjs/http-idempotency—@Idempotent({ ttlMs, keyPrefix?, required?, lockTtlMs?, scope? })method decorator for mutating routes.HttpIdempotencyModulebridges it to the cache and lock services inafterLoad.@modularityjs/cache+ a driver — stores the first 2xx response (status + JSON body) under the key forttlMs.@modularityjs/lock+ a driver — serializes concurrent duplicates: while the first request is in flight, a duplicate with the same key gets a 409ConflictException.
The client sends an Idempotency-Key header (typically a UUID per logical operation) and reuses it on retry. First request executes and its 2xx response is stored; a replay returns the stored status/body with X-Idempotent-Replay: true and never re-runs the handler. Only 2xx is stored — a 4xx/5xx outcome stays retryable instead of poisoning the key for the whole ttlMs.
import { CacheModule } from '@modularityjs/cache';
import { CacheMemoryModule } from '@modularityjs/cache-memory';
import { inversify } from '@modularityjs/di-inversify';
import {
Body,
Controller,
HttpControllersPool,
HttpModule,
Post,
} from '@modularityjs/http';
import { HttpFastifyModule } from '@modularityjs/http-fastify';
import {
HttpIdempotencyModule,
Idempotent,
} from '@modularityjs/http-idempotency';
import { LockModule } from '@modularityjs/lock';
import { LockMemoryModule } from '@modularityjs/lock-memory';
import { createApp, Module, ModularityModule } from '@modularityjs/modularity';
interface ChargeInput {
amount: number;
currency: string;
}
@Controller('/payments')
class PaymentsController {
@Idempotent({ ttlMs: 60_000 })
@Post()
async charge(@Body() input: ChargeInput) {
// executes at most once per Idempotency-Key within ttlMs
return { chargeId: 'ch_1', amount: input.amount, currency: input.currency };
}
@Idempotent({ ttlMs: 60_000, required: true })
@Post('/transfers')
async transfer(@Body() input: ChargeInput) {
// required: true — a request WITHOUT the header is rejected with 422
return { transferId: 'tr_1' };
}
}
@Module({
name: 'payments',
imports: [HttpModule],
providers: [PaymentsController],
pools: [
{
pool: HttpControllersPool,
key: 'payments',
useClass: PaymentsController,
},
],
})
class PaymentsModule {}
const app = await createApp({
di: inversify,
modules: [
ModularityModule,
HttpModule.forRoot({ port: 3000 }),
HttpFastifyModule,
CacheModule,
CacheMemoryModule,
LockModule,
LockMemoryModule,
HttpIdempotencyModule,
PaymentsModule,
],
});Memory drivers make idempotency per-instance. Behind a load balancer, a retry landing on another instance re-executes. Swap in RedisModule.forRoot({...}), CacheRedisModule, LockRedisModule and both the replay store and the 409 in-flight detection become cross-instance — the controller is unchanged.
The 409 is a feature, not an error to hide. A concurrent duplicate means the first attempt is still running; the correct client behavior is to wait and retry with the same key, then receive the replay. Don't catch it server-side.
Wiring is mandatory once the decorator is used. @Idempotent without HttpIdempotencyModule in the modules array fails at the first decorated request with a StateException prefixed MJS0016 naming the missing module — add HttpIdempotencyModule plus cache and lock drivers.
Storage keys are scoped per caller, method, and URL. The stored key is keyPrefix:scope:METHOD:url:key, so one client key never collides across routes or across callers; distinct query strings are distinct entries. Response headers are not replayed — only status and JSON body (declare headers with @SetHeader from @modularityjs/http if a replay must carry them).
Never let an unscoped key identify an entry. Keys are chosen by the client, so without a caller segment one caller can replay another's response body. scope defaults to the authenticated id (request.auth) and falls back to the client IP, which fails closed; override it for tenant- or API-key-scoped apps: @Idempotent({ ttlMs, scope: (request) => \tenant:${request.headers['x-tenant-id']}` })`.
lockTtlMs is the lease, ttlMs is the replay window. They are separate on purpose. The lease (60s default) only has to outlive the handler; sizing it to a 24h replay window means a holder that dies mid-handler wedges that key with 409s for 24 hours.