Skip to content

Installation

1. Install

bash
npm install @nestarc/idempotency

Install the peer dependency for the storage adapter you plan to use:

bash
# RedisStorage
npm install ioredis

# PostgresStorage
npm install pg

2. Register the module

Use MemoryStorage only for local development and tests:

typescript
// app.module.ts
import { Module } from '@nestjs/common';
import { IdempotencyModule, MemoryStorage } from '@nestarc/idempotency';

@Module({
  imports: [
    IdempotencyModule.forRoot({
      storage: new MemoryStorage(),
      ttl: 86400,
    }),
  ],
})
export class AppModule {}

For production, use shared storage so every application replica sees the same idempotency records.

RedisStorage

typescript
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { IdempotencyModule, RedisStorage } from '@nestarc/idempotency';
import { Redis } from 'ioredis';

@Module({
  imports: [
    IdempotencyModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        storage: new RedisStorage({
          client: new Redis({
            host: config.get<string>('REDIS_HOST', 'localhost'),
            port: Number(config.get('REDIS_PORT') ?? 6379),
          }),
        }),
        ttl: Number(config.get('IDEMPOTENCY_TTL') ?? 86400),
        processingTtl: Number(config.get('IDEMPOTENCY_PROCESSING_TTL') ?? 120),
      }),
    }),
  ],
})
export class AppModule {}

If you pass client, your application owns the Redis lifecycle. If you pass connection, RedisStorage creates the client and closes it through Nest's OnModuleDestroy hook.

PostgresStorage

typescript
import { Module } from '@nestjs/common';
import { Pool } from 'pg';
import { IdempotencyModule, PostgresStorage } from '@nestarc/idempotency';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

@Module({
  imports: [
    IdempotencyModule.forRoot({
      storage: new PostgresStorage({ pool }),
      ttl: 86400,
      processingTtl: 120,
    }),
  ],
})
export class AppModule {}

Create the Postgres schema through your migration tooling:

bash
psql "$DATABASE_URL" -f node_modules/@nestarc/idempotency/sql/init.sql

For tests or scripts you can call the helper:

typescript
import { PostgresStorage } from '@nestarc/idempotency';

await PostgresStorage.createSchema(pool);

autoCreateSchema: true exists for development, but production should use an explicit migration.

typescript
new PostgresStorage({ pool, autoCreateSchema: true });

3. Wire the interceptor

The module does not auto-register the interceptor. Choose one of these patterns:

typescript
// 1. App-global: the interceptor is available everywhere.
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { IdempotencyInterceptor } from '@nestarc/idempotency';

@Module({
  providers: [{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor }],
})
export class AppModule {}
typescript
// 2. Controller-scoped.
import { Controller, UseInterceptors } from '@nestjs/common';
import { IdempotencyInterceptor } from '@nestarc/idempotency';

@Controller('payments')
@UseInterceptors(IdempotencyInterceptor)
export class PaymentsController {}
typescript
// 3. Method-scoped.
import { Post, UseInterceptors } from '@nestjs/common';
import { Idempotent, IdempotencyInterceptor } from '@nestarc/idempotency';

@Post()
@UseInterceptors(IdempotencyInterceptor)
@Idempotent()
createPayment() {}

Only handlers decorated with @Idempotent() are processed. Routes without the decorator pass through untouched.

4. Decorate write endpoints

typescript
import { Body, Controller, Post, UseInterceptors } from '@nestjs/common';
import { Idempotent, IdempotencyInterceptor } from '@nestarc/idempotency';

@Controller('payments')
@UseInterceptors(IdempotencyInterceptor)
export class PaymentsController {
  @Post()
  @Idempotent()
  createPayment(@Body() dto: CreatePaymentDto) {
    return this.paymentService.process(dto);
  }

  @Post('refunds')
  @Idempotent({ ttl: 86400, processingTtl: 60 })
  refund(@Body() dto: RefundDto) {
    return this.paymentService.refund(dto);
  }
}

The first request creates a PROCESSING record and runs the handler. A completed retry with the same key and matching fingerprint replays the original response instead of running the handler again.

Module options

OptionTypeDefaultDescription
storageIdempotencyStoragerequiredStorage adapter instance. Use MemoryStorage, RedisStorage, PostgresStorage, or a custom adapter.
ttlnumber86400Completed replay record TTL, in seconds. Per-handler can override.
processingTtlnumbersame as ttlIn-flight PROCESSING record TTL, in seconds. Use this to recover stuck records sooner than completed replay records.
headerNamestring'Idempotency-Key'HTTP header carrying the key.
keyResolver(ctx) => string | undefined | Promise<string | undefined>header lookupResolve keys from webhook event ids, command ids, or other deterministic application values.
maxKeyLengthnumber255Maximum accepted key length. Longer keys return 400.
fingerprintboolean | resolvertrueStable JSON SHA-256 body fingerprint, or a custom deterministic fingerprint resolver.
scope'endpoint' | 'global' | function'endpoint'Storage-key namespace strategy.
replayHeadersboolean | string[]trueReplay default safe headers, use an explicit allowlist, or disable header replay.
observability{ onEvent?, exposeStatusHeaders? }status headers onEmit redacted outcome events and expose idempotency status headers.
isGlobalbooleantrueRegister the module as a Nest global module.

Decorator options

OptionTypeDefaultDescription
requiredbooleantrueIf true and no key is resolved, the interceptor returns 400. If false, missing keys pass through.
ttlnumberinheritOverride the module-level completed replay TTL.
processingTtlnumberinheritOverride the module-level processing TTL.
keyResolverkey resolver functioninheritOverride module-level key resolution for this handler.
maxKeyLengthnumberinheritOverride module-level key length validation.
fingerprintboolean | resolverinheritOverride the module-level fingerprint behavior.

Scope

The scope option controls how the storage key is derived from the raw key value.

ValueBehavior
'endpoint'Default. Prefixes HTTP_METHOD /actual/path:: and excludes the query string, for example POST /payments/pay_1/capture::client-key.
'global'Uses the raw key as-is. Use only when clients guarantee globally unique keys across all endpoints.
functionReturns a custom namespace. Useful for tenant-aware APIs or domain-specific scoping.

The default endpoint scope uses the actual request path, not only the route template. That means /orders/1/capture and /orders/2/capture do not collide when they receive the same raw key. Query strings are intentionally excluded because ?a=1&b=2 and ?b=2&a=1 should not accidentally create two idempotency namespaces. If query values must participate, use a custom scope:

typescript
import { ExecutionContext } from '@nestjs/common';
import { IdempotencyModule, RedisStorage } from '@nestarc/idempotency';

IdempotencyModule.forRoot({
  storage: new RedisStorage({ connection: { host: 'localhost', port: 6379 } }),
  scope: (ctx: ExecutionContext) => {
    const req = ctx.switchToHttp().getRequest<{
      user: { tenantId: string };
      originalUrl?: string;
    }>();
    return `${req.user.tenantId}:${req.originalUrl ?? ''}`;
  },
});

Processing leases

ttl is how long a completed response can be replayed. processingTtl is how long an in-flight lock stays around if the process crashes before completing.

typescript
IdempotencyModule.forRoot({
  storage: new RedisStorage({ connection: { host: 'localhost', port: 6379 } }),
  ttl: 86400,
  processingTtl: 120,
});

Choose processingTtl above the endpoint's real p99 processing time. A lease shorter than the handler's execution time can allow a retry to acquire the key while the original request is still running.

Custom key and fingerprint resolvers

Use keyResolver when the stable idempotency key comes from the request body instead of the Idempotency-Key header, such as webhook event ids or command ids.

typescript
import { Body, Controller, Post } from '@nestjs/common';
import { Idempotent } from '@nestarc/idempotency';

interface StripeEvent {
  id: string;
  type: string;
  data: { object: { id: string } };
}

@Controller('webhooks')
export class WebhooksController {
  @Post('stripe')
  @Idempotent({
    keyResolver: (ctx) => {
      const req = ctx.switchToHttp().getRequest<{ body: StripeEvent }>();
      return req.body.id;
    },
    fingerprint: ({ body }) => {
      const event = body as StripeEvent;
      return `${event.type}:${event.data.object.id}`;
    },
  })
  handleStripe(@Body() event: StripeEvent) {
    return this.webhookService.process(event);
  }
}

The default boolean fingerprint uses stable JSON serialization. A custom resolver replaces that with your semantic fingerprint, so it must be deterministic for the same logical request.

Response header replay

By default, the interceptor captures and replays a conservative header allowlist:

  • Content-Type
  • Location
  • ETag
  • Cache-Control
  • custom X-* headers

Unsafe or hop-by-hop headers such as Set-Cookie, Connection, and Transfer-Encoding are denied. Authorization is not in the default allowlist; do not add it to an explicit allowlist for public APIs.

typescript
IdempotencyModule.forRoot({
  storage: new MemoryStorage(),
  replayHeaders: true,
});

IdempotencyModule.forRoot({
  storage: new MemoryStorage(),
  replayHeaders: ['location', 'x-request-id'],
});

IdempotencyModule.forRoot({
  storage: new MemoryStorage(),
  replayHeaders: false,
});

Observability

v0.4.0 emits redacted outcome events and status headers.

typescript
IdempotencyModule.forRoot({
  storage: new MemoryStorage(),
  observability: {
    onEvent: (event) => {
      console.log('idempotency', event.outcome, event.keyHash);
    },
  },
});

Status headers are enabled by default:

HeaderExample
Idempotency-Statuscreated, replayed, conflict, mismatch, bypassed, stale, complete_error
Idempotency-Replayedtrue on replayed responses

Disable client-visible status headers if your public API contract should not expose them:

typescript
IdempotencyModule.forRoot({
  storage: new MemoryStorage(),
  observability: { exposeStatusHeaders: false },
});

Production checklist

  • Use RedisStorage or PostgresStorage for all multi-replica deployments.
  • Keep fingerprint: true unless you provide a deterministic semantic fingerprint.
  • Keep scope: 'endpoint' unless clients guarantee globally unique keys.
  • Set processingTtl above the endpoint p99 and below the completed replay ttl.
  • Use a completed replay ttl aligned with your client retry window, usually 24 hours for payment-like APIs.
  • Return plain JSON from idempotent endpoints. Buffers, typed arrays, and streams intentionally bypass replay caching.
  • Decide whether Location, ETag, Cache-Control, and X-* replay is part of your API contract.
  • Add the Postgres response_headers JSONB migration if upgrading from pre-v0.3.0 Postgres storage.
  • Monitor created, replayed, conflict, mismatch, stale, and complete_error outcomes.
  • Test duplicate requests with the same key, different payload, concurrent requests, and missing-key behavior before rollout.

Migration guide

From v0.2.x to v0.3.x

Add the response header column before enabling header replay with Postgres storage:

sql
ALTER TABLE idempotency_records
  ADD COLUMN IF NOT EXISTS response_headers JSONB;

Endpoint scoping now uses the actual request path and excludes the query string. If an endpoint previously relied on query values as part of the idempotency namespace, configure a custom scope function.

Stable JSON fingerprinting can turn previous false 422 mismatches into replays when the request bodies differ only by object key order.

From v0.3.x to v0.4.x

No storage migration is required for the v0.4.0 options. Review these behavior changes before rollout:

  • Idempotency-Status and Idempotency-Replayed headers are exposed by default. Set observability: { exposeStatusHeaders: false } if you do not want client-visible status headers.
  • maxKeyLength defaults to 255; longer resolved keys return 400.
  • processingTtl can now be configured separately from ttl. Leaving it unset preserves the earlier single-TTL behavior.
  • keyResolver and custom fingerprint resolvers are available for webhook and command-style APIs.

Released under the MIT License.