Skip to content

nestarc/idempotency

Repository files navigation

@nestarc/idempotency

IETF-draft-compliant idempotency module for NestJS — decorator-based, pluggable storage (memory/Redis), response replay, fingerprint validation.

CI npm version license node NestJS provenance

Why

Non-idempotent HTTP methods (POST, PATCH, DELETE) can be processed multiple times when:

  • A client times out and the user retries the request
  • An API gateway or load balancer auto-retries
  • A flaky mobile network resends a request without realizing the first attempt succeeded
  • Microservices duplicate messages between hops

The result is double charges, duplicate orders, and corrupt state. The IETF draft httpapi-idempotency-key-header-07 standardizes a solution: clients send an Idempotency-Key header with a unique value, and the server enforces "exactly-once" semantics by replaying the original response on retries.

@nestarc/idempotency is a clean-room NestJS implementation of that draft, with a one-line decorator API and pluggable storage.

Install

npm install @nestarc/idempotency

If you plan to use the Redis storage adapter, also install ioredis:

npm install ioredis

Quick start

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

@Module({
  imports: [
    IdempotencyModule.forRoot({
      storage: new MemoryStorage(),
      ttl: 86400, // 24 hours
    }),
  ],
})
export class AppModule {}
// payments.controller.ts
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) {
    // Your business logic. Runs at most once per Idempotency-Key.
    return this.paymentService.process(dto);
  }
}

That's it. A duplicate POST /payments with the same Idempotency-Key header will replay the cached response without re-running your handler.

Three ways to wire the interceptor

The module deliberately does not auto-register the interceptor — you opt in with one of these patterns:

// 1. App-global — applies to every controller
import { APP_INTERCEPTOR } from '@nestjs/core';
import { IdempotencyInterceptor } from '@nestarc/idempotency';

@Module({
  providers: [{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor }],
})
export class AppModule {}
// 2. Controller-scoped
@Controller('payments')
@UseInterceptors(IdempotencyInterceptor)
export class PaymentsController { ... }
// 3. Method-scoped
@Post()
@UseInterceptors(IdempotencyInterceptor)
@Idempotent()
createPayment() { ... }

In all three cases, only handlers decorated with @Idempotent() are processed. Routes without the decorator pass through untouched.

Redis storage

import { IdempotencyModule, RedisStorage } from '@nestarc/idempotency';
import { Redis } from 'ioredis';

const client = new Redis({ host: 'localhost', port: 6379 });

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

Or async via ConfigService:

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('REDIS_HOST'),
            port: config.get('REDIS_PORT'),
          }),
        }),
        ttl: config.get('IDEMPOTENCY_TTL', 86400),
      }),
    }),
  ],
})
export class AppModule {}

Configuration reference

Module options (IdempotencyModule.forRoot(...))

Option Type Default Description
storage IdempotencyStorage (required) A storage adapter instance (e.g. new MemoryStorage()).
ttl number (seconds) 86400 Default time-to-live for records. Per-handler can override.
headerName string 'Idempotency-Key' HTTP header carrying the key. Defaults to the IETF standard.
fingerprint boolean true Compute a SHA-256 fingerprint of the request body.
scope IdempotencyScope 'endpoint' How storage keys are namespaced. See Scope below.
isGlobal boolean true Register as a NestJS global module.

Scope

The scope option controls how the storage key is derived from the raw header value. It matters when two different endpoints might receive the same Idempotency-Key value from a client.

Value Behavior
'endpoint' Default. Prepends HTTP_METHOD /route:: to the key, using the actual route path read from NestJS PATH_METADATA (e.g. POST /payments::my-key). Two endpoints with different route paths are isolated even if their controller classes share a name (v1/v2 APIs, same-named controllers across modules). If the handler has no PATH_METADATA (custom decorators, non-NestJS integrations), it gracefully falls back to ControllerClassName#methodName::.
'global' Use the raw header value as-is. Safe only if clients guarantee globally-unique keys across all endpoints (Stripe-style).
function (ctx: ExecutionContext) => string. Fully custom scoping — useful in multi-tenant systems where the scope should include the tenant id. The returned string is joined to the raw key with ::.
// Multi-tenant example: include the tenant ID in the scope.
IdempotencyModule.forRoot({
  storage: new MemoryStorage(),
  scope: (ctx) => {
    const req = ctx.switchToHttp().getRequest();
    return `${req.user.tenantId}`;
  },
});

Decorator options (@Idempotent(options?))

Option Type Default Description
required boolean true If true and the header is missing, the interceptor returns 400. If false, pass-through.
ttl number inherit Override the module-level TTL for this handler (seconds).
fingerprint boolean inherit Override the module-level fingerprint setting.

How it works

Client Request (with Idempotency-Key header)
    │
    ▼
[IdempotencyInterceptor]
    │
    ├─ 1. Read metadata + Idempotency-Key header
    │     ├─ no @Idempotent → pass through
    │     ├─ missing header + required=true → 400 Bad Request
    │     └─ resolve TTL (reject 0/negative/fractional/NaN/Infinity)
    │
    ├─ 2. Apply scope to the key
    │     (default: `HTTP_METHOD /route::` from NestJS PATH_METADATA)
    │
    ├─ 3. Look up the scoped key in storage
    │     ├─ COMPLETED + fingerprint match       → replay cached response
    │     ├─ fingerprint mismatch (any status)   → 422 Unprocessable Entity
    │     ├─ PROCESSING                           → 409 Conflict
    │     └─ not found                            → step 4
    │
    ├─ 4. Atomically create a PROCESSING record (token-based NX)
    │     ├─ acquired=true  → step 5 with the returned token
    │     └─ acquired=false → re-read the record and loop back to step 3
    │                         (the winner may be COMPLETED → replay,
    │                          COMPLETED+mismatch → 422, or PROCESSING → 409)
    │
    ├─ 5. Run the controller handler
    │
    └─ 6. Capture the response
          ├─ plain JSON             → storage.complete(token, statusCode, body)
          │   ├─ 'ok'               → emit handler value
          │   ├─ 'stale' (TTL race) → warn + emit (don't clobber newer record)
          │   └─ throws (transient) → ERROR log + emit (don't delete — retries
          │                            hit 409 until TTL reclaims the record,
          │                            never duplicate execution)
          ├─ Buffer / stream / etc. → bypass cache + warn + emit + delete
          └─ handler threw          → delete record (best-effort) + rethrow

The interceptor uses RxJS concatMap to ensure the storage write completes before the response is emitted to the client — preventing a race window where a duplicate request arriving microseconds later could observe the wrong state.

Storage adapters implement token-based compare-and-set: each create() returns an opaque token that the interceptor passes back to complete() / delete(). A slow caller whose PROCESSING record was evicted by TTL and replaced by a newer request cannot clobber the newer record — the storage returns 'stale' and the interceptor logs a warning while still emitting the handler's value to the original caller.

Error reference

Status When IETF rationale
400 Idempotency-Key header is missing and required: true (the default), or a configured ttl is not a positive integer client contract / developer error
409 The record under this scoped key is currently PROCESSING — either observed on the initial read or after losing an atomic create() race to a winner still in flight concurrent duplicate
422 A record exists under this scoped key with a different request-body fingerprint (reused key with new payload) key reused with new payload

Note that v0.1.3+ returns a replay (not a 409) when the race winner has already finished — the interceptor re-reads the record on a lost create() race and dispatches through the same state machine as the initial-read branch.

Storage adapters

Feature MemoryStorage RedisStorage
Scope single process shared across replicas
Persistence none (lost on restart) full Redis durability
TTL mechanism setTimeout Redis EXPIRE
Cluster-safe
Production-ready ❌ (dev/test only)
Required peer none ioredis ^5

Custom storage adapters

Implement the IdempotencyStorage interface. The contract is token-based compare-and-set: create() returns an opaque token, and complete() / delete() require the caller to pass the matching token back. This prevents a slow caller whose record was evicted by TTL from clobbering a newer caller's record.

import type {
  IdempotencyStorage,
  IdempotencyRecord,
  CreateResult,
  CompleteResponse,
  MutateResult,
} from '@nestarc/idempotency';
import type { OnModuleDestroy } from '@nestjs/common';

class MyStorage implements IdempotencyStorage, OnModuleDestroy {
  async get(key: string): Promise<IdempotencyRecord | null> {
    // Return the record, or null if it doesn't exist / has expired.
  }

  async create(
    key: string,
    fingerprint: string | undefined,
    ttlSeconds: number,
  ): Promise<CreateResult> {
    // NX semantics: if the key already exists, return { acquired: false }.
    // Otherwise, generate an opaque token (e.g. randomUUID()), persist it
    // alongside the PROCESSING record, and return { acquired: true, token }.
    // `createdAt` must equal the moment of creation and be preserved
    // verbatim across subsequent complete() calls.
  }

  async complete(
    key: string,
    token: string,
    response: CompleteResponse,
    ttlSeconds: number,
  ): Promise<MutateResult> {
    // Compare-and-set: only mutate the record if its stored token matches
    // the caller's. Return 'ok' on success; return 'stale' if the token
    // does NOT match (the original record was evicted and replaced) or if
    // the record is missing. Refresh `expiresAt` to now + ttlSeconds, but
    // preserve the original `createdAt`.
  }

  async delete(key: string, token: string): Promise<MutateResult> {
    // Idempotent cleanup: return 'ok' if the record matched-and-was-removed
    // OR was already absent. Return 'stale' only if a DIFFERENT record
    // (with a different token) exists under this key — in that case, do
    // NOT remove it.
  }

  // Optional but recommended: Nest will call this during app.close().
  async onModuleDestroy(): Promise<void> {
    // Release any external resources (DB connections, timers, ...).
  }
}

Then pass an instance to IdempotencyModule.forRoot({ storage: new MyStorage() }).

The package ships a shared contract test suite at test/support/shared-storage-contract.ts (in the source tree, not exported) that encodes every behavioral guarantee above. Custom adapters are encouraged to copy it into their own repo and plug in via describeStorageContract('MyStorage', factory) to catch LSP drift before it ships.

IETF spec compliance

This package targets draft-ietf-httpapi-idempotency-key-header-07. As of v0.1.3 it covers:

  • Idempotency-Key header recognition (configurable name)
  • ✅ Atomic key creation with NX semantics (both adapters)
  • Token-based compare-and-set on every mutation — a slow caller whose record was evicted by TTL cannot clobber a newer caller's record
  • ✅ Response replay for completed requests (matching fingerprint)
  • 409 Conflict only when the winner is genuinely still in flight (not for lost races against already-completed winners)
  • 422 Unprocessable Entity for fingerprint mismatch — priority over PROCESSING state per draft semantics
  • ✅ Configurable TTL with per-endpoint override and boundary validation (positive integer only)
  • Per-endpoint key scoping via PATH_METADATA — the draft's "(key, request URI)" recommendation is implemented as HTTP_METHOD /route::rawKey, matching v1/v2 API isolation out of the box
  • ✅ Binary response detection — Buffer, typed arrays, and Node/Web streams are bypassed rather than cached as JSON garbage
  • Transient storage-write failures do NOT cause duplicate execution — a failing complete() is caught and the handler's response is still emitted to the caller

Deferred to future versions:

  • 🚧 Response header replay (v0.2)
  • 🚧 PostgreSQL storage adapter (v0.2)
  • 🚧 Fastify adapter verification (v0.2)
  • 🚧 Stable JSON stringify for fingerprint (v0.2)
  • 🚧 Dual ESM/CJS build (v0.2)

Caveats (v0.1)

  • Body fingerprint uses insertion-order JSON.stringify — clients should send stable JSON. Two requests with the same fields in different orders will hash differently and be treated as a fingerprint mismatch.
  • Only plain-JSON responses are cached. Buffers, typed arrays, Node streams, and Web ReadableStream are actively detected and bypass caching with a logged warning — the handler still runs and the caller still gets the response, but there is no replay for binary endpoints.
  • Response headers are not replayed in v0.1. The cached response carries the original status code and body only.
  • Express adapter only. Fastify is not yet verified.
  • TTL-expiry race is closed via token-based CAS. A slow request whose PROCESSING record has been evicted by TTL cannot clobber a newer request's record under the same key — the storage refuses the write and the interceptor logs a stale token warning while still emitting the handler's response to the caller.

Roadmap

  • v0.2: PostgreSQL storage adapter (Prisma), response header replay, Fastify verification, dual ESM/CJS build, stable stringify
  • v0.3: Custom fingerprint functions, metrics (hit rate, conflict rate), business-error caching option, Swagger/OpenAPI integration

License

MIT — see LICENSE.

About

IETF-compliant idempotency module for NestJS — @idempotent() decorator, Redis/PostgreSQL storage, response replay, race condition protection

Resources

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors