Skip to content

Idempotency Wiring

Recipes are working wiring examples with the sharp edges annotated. This page and the idempotency-wiring recipe 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. HttpIdempotencyModule bridges it to the cache and lock services in afterLoad.
  • @modularityjs/cache + a driver — stores the first 2xx response (status + JSON body) under the key for ttlMs.
  • @modularityjs/lock + a driver — serializes concurrent duplicates: while the first request is in flight, a duplicate with the same key gets a 409 ConflictException.

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.

typescript
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.