npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@nestarc/idempotency

v0.1.3

Published

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

Readme

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