{"_id":"@backendkit-labs/idempotency","_rev":"3-4b5c54cadc5a21f3c599807d5d4383b9","name":"@backendkit-labs/idempotency","dist-tags":{"latest":"0.1.2"},"versions":{"0.1.0":{"name":"@backendkit-labs/idempotency","version":"0.1.0","keywords":["idempotency","idempotent","nestjs","redis","cache","duplicate-prevention","node","backend"],"author":{"name":"BackendKit Labs","email":"hello@backendkitlabs.dev"},"license":"Apache-2.0","_id":"@backendkit-labs/idempotency@0.1.0","maintainers":[{"name":"backendkit.dev","email":"backendkit.dev@gmail.com"}],"homepage":"https://backendkitlabs.dev/docs/idempotency/","bugs":{"url":"https://github.com/BackendKit-labs/backendkit-monorepo/issues"},"dist":{"shasum":"68c4af29d5e742ac82feffa6d4ea8710483a5fb5","tarball":"https://registry.npmjs.org/@backendkit-labs/idempotency/-/idempotency-0.1.0.tgz","fileCount":7,"integrity":"sha512-co9A9XKxuvC/0jHAXYYIMss/FE4K+VQfVCjlc6mu7i9XfEZdnjsbs8wqXtAlc0ACWw0AXordrt2Xh5Q8GPKvuQ==","signatures":[{"sig":"MEUCIAmThC2IfCK7h8H+eBFJVfFkPchj7CX0vC5Boyzv/HmLAiEAvtwXkoRt0tmsg2K4HtM+xeJUBUNcyrABYeQhmEKRgVY=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":74827},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=18"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"}},"gitHead":"bc4b053647483ba43c6db9ef3f297d2eb99dd3b8","scripts":{"dev":"tsup --watch","lint":"eslint src/","test":"vitest run","build":"tsup","typecheck":"tsc --noEmit","test:watch":"vitest","test:coverage":"vitest run --coverage","prepublishOnly":"npm run build && npm run test && npm run lint"},"_npmUser":{"name":"backendkit.dev","email":"backendkit.dev@gmail.com"},"repository":{"url":"git+https://github.com/BackendKit-labs/backendkit-monorepo.git","type":"git","directory":"packages/idempotency"},"_npmVersion":"11.8.0","description":"Idempotency key enforcement for NestJS — replay cached responses, prevent duplicate mutations, pluggable store (in-memory / Redis)","directories":{},"sideEffects":false,"_nodeVersion":"22.16.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"rxjs":"^7.8.0","tsup":"^8.0.0","eslint":"^9.0.0","vitest":"^2.0.0","@eslint/js":"^9.39.4","typescript":"^5.5.0","@types/node":"^22.0.0","@nestjs/core":"^10.0.0","@nestjs/common":"^10.0.0","reflect-metadata":"^0.2.0","typescript-eslint":"^8.59.3"},"peerDependencies":{"rxjs":">=7.0.0","@nestjs/core":">=10.0.0","@nestjs/common":">=10.0.0"},"peerDependenciesMeta":{"rxjs":{"optional":false},"@nestjs/core":{"optional":false},"@nestjs/common":{"optional":false}},"_npmOperationalInternal":{"tmp":"tmp/idempotency_0.1.0_1779155758790_0.15624696585239817","host":"s3://npm-registry-packages-npm-production"}},"0.1.1":{"name":"@backendkit-labs/idempotency","version":"0.1.1","keywords":["idempotency","idempotent","nestjs","redis","cache","duplicate-prevention","node","backend"],"author":{"name":"BackendKit Labs","email":"hello@backendkitlabs.dev"},"license":"Apache-2.0","_id":"@backendkit-labs/idempotency@0.1.1","maintainers":[{"name":"backendkit.dev","email":"backendkit.dev@gmail.com"}],"homepage":"https://backendkitlabs.dev/docs/idempotency/","bugs":{"url":"https://github.com/BackendKit-labs/backendkit-monorepo/issues"},"dist":{"shasum":"81b50fb8c436064434ed133769462b0eb6eafab9","tarball":"https://registry.npmjs.org/@backendkit-labs/idempotency/-/idempotency-0.1.1.tgz","fileCount":8,"integrity":"sha512-vLmh13u6yVkoNjD30T9fvH+0rzaNV2F4QUMiGGxQzEzdFZ4dzRTN8gdVxVyM+UGMLlBQ1Izh+X1YCzta8azlbA==","signatures":[{"sig":"MEUCIHiehy+XC07hvfBHJM+x73KxItNgo1PdDdSW+SD5zoXHAiEArnp8aH3/OcDX/3h57WtwFZRojTzee/6Hr9THvT51kv4=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":89579},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=18"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"}},"gitHead":"fe4d18c37090848c6ca2371fb03b7f0561d08431","scripts":{"dev":"tsup --watch","lint":"eslint src/","test":"vitest run","build":"tsup","typecheck":"tsc --noEmit","test:watch":"vitest","test:coverage":"vitest run --coverage","prepublishOnly":"npm run build && npm run test && npm run lint"},"_npmUser":{"name":"backendkit.dev","email":"backendkit.dev@gmail.com"},"repository":{"url":"git+https://github.com/BackendKit-labs/backendkit-monorepo.git","type":"git","directory":"packages/idempotency"},"_npmVersion":"11.8.0","description":"Idempotency key enforcement for NestJS — replay cached responses, prevent duplicate mutations, pluggable store (in-memory / Redis)","directories":{},"sideEffects":false,"_nodeVersion":"22.16.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"rxjs":"^7.8.0","tsup":"^8.0.0","eslint":"^9.0.0","vitest":"^2.0.0","@eslint/js":"^9.39.4","typescript":"^5.5.0","@types/node":"^22.0.0","@nestjs/core":"^10.0.0","@nestjs/common":"^10.0.0","reflect-metadata":"^0.2.0","typescript-eslint":"^8.59.3"},"peerDependencies":{"rxjs":">=7.0.0","@nestjs/core":">=10.0.0","@nestjs/common":">=10.0.0"},"peerDependenciesMeta":{"rxjs":{"optional":false},"@nestjs/core":{"optional":false},"@nestjs/common":{"optional":false}},"_npmOperationalInternal":{"tmp":"tmp/idempotency_0.1.1_1779156253091_0.6922091886573405","host":"s3://npm-registry-packages-npm-production"}},"0.1.2":{"name":"@backendkit-labs/idempotency","version":"0.1.2","license":"Apache-2.0","author":{"name":"BackendKit Labs","email":"hello@backendkitlabs.dev"},"description":"Idempotency key enforcement for NestJS — replay cached responses, prevent duplicate mutations, pluggable store (in-memory / Redis)","type":"module","main":"./dist/index.cjs","module":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"}},"scripts":{"build":"tsup","dev":"tsup --watch","test":"vitest run","test:watch":"vitest","test:coverage":"vitest run --coverage","typecheck":"tsc --noEmit","lint":"eslint src/","prepublishOnly":"npm run build && npm run test && npm run lint"},"keywords":["idempotency","idempotent","nestjs","redis","cache","duplicate-prevention","node","backend"],"homepage":"https://backendkitlabs.dev/docs/idempotency/","repository":{"type":"git","url":"git+https://github.com/BackendKit-labs/backendkit-monorepo.git","directory":"packages/idempotency"},"bugs":{"url":"https://github.com/BackendKit-labs/backendkit-monorepo/issues"},"publishConfig":{"access":"public"},"sideEffects":false,"engines":{"node":">=18"},"peerDependencies":{"@nestjs/common":">=10.0.0","@nestjs/core":">=10.0.0","rxjs":">=7.0.0"},"peerDependenciesMeta":{"@nestjs/common":{"optional":false},"@nestjs/core":{"optional":false},"rxjs":{"optional":false}},"devDependencies":{"@eslint/js":"^9.39.4","@nestjs/common":"^10.0.0","@nestjs/core":"^10.0.0","@types/node":"^22.0.0","eslint":"^9.0.0","reflect-metadata":"^0.2.0","rxjs":"^7.8.0","tsup":"^8.0.0","typescript":"^5.5.0","typescript-eslint":"^8.59.3","vitest":"^2.0.0"},"gitHead":"19f67d13d36ceb4ad180208a0eb90a4ee89c51b3","_id":"@backendkit-labs/idempotency@0.1.2","_nodeVersion":"22.16.0","_npmVersion":"11.8.0","dist":{"integrity":"sha512-aDymyQYGf3RLbr4LsDxX35BW+jlI/9qUvFyMVf4X3Z6gTSE2Srr5Y07Ffn6rppNQ7Pvs90sHbtutARGMhtRSgw==","shasum":"d7aeefedc1d8852ee8bcb39ab5579bf264cdc5eb","tarball":"https://registry.npmjs.org/@backendkit-labs/idempotency/-/idempotency-0.1.2.tgz","fileCount":8,"unpackedSize":89579,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEMCIGw13SwTi6RBkrOr0ucGPufvnEMsaQ8KB7QW2S+JtiJJAh8wjqzu9mtQ4bycGorWc0HnJBRZ1Q2r94cLb/MVwspj"}]},"_npmUser":{"name":"backendkit.dev","email":"backendkit.dev@gmail.com"},"directories":{},"maintainers":[{"name":"backendkit.dev","email":"backendkit.dev@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/idempotency_0.1.2_1779156802124_0.919146847914146"},"_hasShrinkwrap":false}},"time":{"created":"2026-05-19T01:55:58.696Z","modified":"2026-05-19T02:13:22.416Z","0.1.0":"2026-05-19T01:55:58.943Z","0.1.1":"2026-05-19T02:04:13.243Z","0.1.2":"2026-05-19T02:13:22.270Z"},"bugs":{"url":"https://github.com/BackendKit-labs/backendkit-monorepo/issues"},"author":{"name":"BackendKit Labs","email":"hello@backendkitlabs.dev"},"license":"Apache-2.0","homepage":"https://backendkitlabs.dev/docs/idempotency/","keywords":["idempotency","idempotent","nestjs","redis","cache","duplicate-prevention","node","backend"],"repository":{"type":"git","url":"git+https://github.com/BackendKit-labs/backendkit-monorepo.git","directory":"packages/idempotency"},"description":"Idempotency key enforcement for NestJS — replay cached responses, prevent duplicate mutations, pluggable store (in-memory / Redis)","maintainers":[{"name":"backendkit.dev","email":"backendkit.dev@gmail.com"}],"readme":"# @backendkit-labs/idempotency\n\n[![npm version](https://img.shields.io/npm/v/@backendkit-labs/idempotency?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@backendkit-labs/idempotency)\n[![CI](https://img.shields.io/github/actions/workflow/status/BackendKit-labs/backendkit-monorepo/ci.yml?style=flat-square&label=CI)](https://github.com/BackendKit-labs/backendkit-monorepo/actions/workflows/ci.yml)\n[![License](https://img.shields.io/npm/l/@backendkit-labs/idempotency?style=flat-square)](LICENSE)\n[![Node](https://img.shields.io/node/v/@backendkit-labs/idempotency?style=flat-square)](package.json)\n[![Docs](https://img.shields.io/badge/docs-backendkitlabs.dev-4f7eff?style=flat-square)](https://backendkitlabs.dev/docs/idempotency/)\n\n> Idempotency key enforcement for NestJS — replay cached responses, prevent duplicate mutations.\n\nA client that retries a timed-out `POST /orders` request should not create two orders. This library intercepts duplicate requests at the HTTP layer, returns the original response from a store, and sets an `Idempotent-Replayed: true` header so the client knows it received a cached result — without any changes to your business logic.\n\nKey design decisions: the **composite key** (`METHOD:path:client-key`) isolates the same client key across different endpoints. The **store interface** is pluggable — `InMemoryIdempotencyStore` works out of the box; `RedisIdempotencyStore` uses `SET NX EX` (a single atomic command) to prevent race conditions across multiple instances. When a handler throws, the key is **deleted from the store** so the client can retry with the same key.\n\n---\n\n## Table of Contents\n\n- [Installation](#installation)\n- [Quick Start](#quick-start)\n- [Core Concepts](#core-concepts)\n  - [Key Lifecycle](#key-lifecycle)\n  - [Composite Key](#composite-key)\n  - [Pending Conflict Strategies](#pending-conflict-strategies)\n  - [Key Validation](#key-validation)\n- [Module Setup](#module-setup)\n  - [forRoot()](#forroot)\n  - [forRootAsync()](#forrootasync)\n  - [Module Options Reference](#module-options-reference)\n- [@Idempotent() Decorator](#idempotent-decorator)\n- [Store Implementations](#store-implementations)\n  - [InMemoryIdempotencyStore](#inmemoryidempotencystore)\n  - [RedisIdempotencyStore](#redisidempotencystore)\n  - [Custom Store](#custom-store)\n- [Error Reference](#error-reference)\n- [Response Headers](#response-headers)\n- [Architecture](#architecture)\n\n---\n\n## Installation\n\n```bash\nnpm install @backendkit-labs/idempotency\n```\n\nPeer dependencies:\n\n```bash\nnpm install @nestjs/common @nestjs/core rxjs\n```\n\n---\n\n## TypeScript Configuration\n\n```json\n{\n  \"compilerOptions\": {\n    \"moduleResolution\": \"bundler\",\n    \"experimentalDecorators\": true,\n    \"emitDecoratorMetadata\": true\n  }\n}\n```\n\nAnd import `reflect-metadata` once at application startup:\n\n```typescript\n// main.ts\nimport 'reflect-metadata';\n```\n\n---\n\n## Quick Start\n\n**1. Register the module (once, in `AppModule`):**\n\n```typescript\nimport { IdempotencyModule } from '@backendkit-labs/idempotency';\n\n@Module({\n  imports: [\n    IdempotencyModule.forRoot({\n      ttlSeconds:      86_400,  // cache responses for 24 h\n      pendingStrategy: 'reject', // 409 while in-flight (default)\n    }),\n  ],\n})\nexport class AppModule {}\n```\n\n**2. Decorate the endpoints that need protection:**\n\n```typescript\nimport { Idempotent } from '@backendkit-labs/idempotency';\n\n@Controller('orders')\nexport class OrdersController {\n  @Post()\n  @HttpCode(HttpStatus.CREATED)\n  @Idempotent()\n  async createOrder(@Body() dto: CreateOrderDto) {\n    return this.ordersService.createOrder(dto);\n  }\n}\n```\n\n**3. Clients send the `Idempotency-Key` header:**\n\n```http\nPOST /orders HTTP/1.1\nContent-Type: application/json\nIdempotency-Key: order-checkout-7f3a9b\n\n{ \"customerId\": \"cust-42\", \"items\": [...] }\n```\n\nFirst call → `201 Created` with the order body.  \nSame key again → `201 Created` with the exact same body + `Idempotent-Replayed: true`.\n\n---\n\n## Core Concepts\n\n### Key Lifecycle\n\n```\nClient sends request with Idempotency-Key\n         │\n         ▼\n  Key exists in store?\n  ├── YES, status=completed → replay cached response (skip handler)\n  ├── YES, status=pending   → apply pendingStrategy (reject 409 / replay 202)\n  └── NO  ─────────────────────────────────────────────────────────────┐\n              Atomically insert pending record                          │\n                       │                                               │\n                       ▼                                               │\n              Execute handler                                          │\n              ├── SUCCESS → store.complete(key, statusCode, body) ─────┤\n              └── ERROR   → store.delete(key)   ← client can retry ◄──┘\n```\n\nOn success, the store entry transitions from `pending` → `completed` with the response body and status code persisted. On error, the key is deleted so the client can retry with the same idempotency key (the error was not a successful response, so there's nothing to replay).\n\n### Composite Key\n\nThe internal store key is always `METHOD:path:client-key`:\n\n```\nPOST:/orders:order-checkout-7f3a9b\nPOST:/payments/charge:order-checkout-7f3a9b\n```\n\nThe same client-supplied key is therefore **isolated per endpoint**. A client can reuse `order-checkout-7f3a9b` across `/orders` and `/payments/charge` without collision.\n\n### Pending Conflict Strategies\n\nWhen two requests with the same key arrive concurrently (before the first one completes), the second sees a `pending` record. The behavior depends on `pendingStrategy`:\n\n| Strategy | Response | Use when |\n|----------|----------|----------|\n| `'reject'` (default) | `409 Conflict` with a descriptive message | Client should wait and retry — safest for mutations |\n| `'replay'` | `202 Accepted` + `Retry-After: 1` | Client will poll until it gets the real response |\n\n```typescript\n// Per-endpoint override\n@Idempotent({ pendingStrategy: 'replay' })\nasync createOrder(@Body() dto: CreateOrderDto) { ... }\n```\n\n### Key Validation\n\nThe `Idempotency-Key` header is validated before the store is touched:\n\n| Condition | Response |\n|-----------|----------|\n| Header missing | `422 Unprocessable Entity` |\n| Header present but not 1–256 printable ASCII characters | `422 Unprocessable Entity` |\n| Valid key, first request | `2xx` (your handler's response) |\n| Valid key, cached response | `2xx` + `Idempotent-Replayed: true` |\n\n---\n\n## Module Setup\n\n### `forRoot()`\n\nSynchronous setup with a plain options object:\n\n```typescript\nimport { IdempotencyModule } from '@backendkit-labs/idempotency';\n\nIdempotencyModule.forRoot({\n  ttlSeconds:      3_600,   // 1 hour\n  pendingStrategy: 'reject',\n  keyHeader:       'idempotency-key', // default — clients send this header\n})\n```\n\n### `forRootAsync()`\n\nAsynchronous setup — useful when options come from `ConfigService` or another injectable:\n\n```typescript\nimport { IdempotencyModule } from '@backendkit-labs/idempotency';\nimport { ConfigService } from '@nestjs/config';\n\nIdempotencyModule.forRootAsync({\n  imports:    [ConfigModule],\n  inject:     [ConfigService],\n  useFactory: (config: ConfigService) => ({\n    ttlSeconds:      config.get<number>('IDEMPOTENCY_TTL_SECONDS', 86_400),\n    pendingStrategy: config.get<'reject' | 'replay'>('IDEMPOTENCY_PENDING_STRATEGY', 'reject'),\n  }),\n})\n```\n\n### Module Options Reference\n\n| Option | Type | Default | Description |\n|--------|------|---------|-------------|\n| `ttlSeconds` | `number` | `86400` | How long to cache a completed response (24 h). |\n| `pendingStrategy` | `'reject' \\| 'replay'` | `'reject'` | What to do when a request arrives while an identical one is in-flight. |\n| `keyHeader` | `string` | `'idempotency-key'` | HTTP header name to read the idempotency key from. |\n\n`IdempotencyModule` is registered as **global** — import it once in `AppModule` and `@Idempotent()` is available everywhere.\n\n---\n\n## `@Idempotent()` Decorator\n\nApplied to individual controller methods. Routes **without** this decorator are completely unaffected — the interceptor does nothing.\n\n```typescript\nimport { Idempotent } from '@backendkit-labs/idempotency';\n\n@Controller('orders')\nexport class OrdersController {\n  // Uses module defaults\n  @Post()\n  @Idempotent()\n  async createOrder(@Body() dto: CreateOrderDto) { ... }\n\n  // Per-endpoint TTL override\n  @Post('bulk')\n  @Idempotent({ ttlSeconds: 300 })\n  async bulkCreate(@Body() dto: BulkCreateDto) { ... }\n\n  // Per-endpoint strategy override\n  @Post('async-job')\n  @Idempotent({ pendingStrategy: 'replay' })\n  async startJob(@Body() dto: JobDto) { ... }\n}\n```\n\n`@Idempotent()` options:\n\n| Option | Type | Default | Description |\n|--------|------|---------|-------------|\n| `ttlSeconds` | `number` | module default | Per-endpoint TTL override. |\n| `pendingStrategy` | `'reject' \\| 'replay'` | module default | Per-endpoint pending strategy override. |\n\n---\n\n## Store Implementations\n\n### `InMemoryIdempotencyStore`\n\nThe default store. No configuration needed — registered automatically by `IdempotencyModule.forRoot()`.\n\n```typescript\n// Used automatically, no setup required\nIdempotencyModule.forRoot({ ttlSeconds: 3600 })\n```\n\n**Characteristics:**\n- Entries expire lazily on the next access (no background timer).\n- Safe under Node.js's single-threaded execution model — `setIfAbsent` is atomic without locks.\n- **Not suitable for multiple instances** — each process has its own map. Use `RedisIdempotencyStore` in production.\n- Does not survive restarts — entries are lost on process exit.\n\n### `RedisIdempotencyStore`\n\nFor production deployments with multiple instances. Atomicity guaranteed by a single `SET key value NX EX ttl` command — no `GET` + `SET` race condition.\n\n```typescript\nimport { IdempotencyModule, RedisIdempotencyStore, IDEMPOTENCY_STORE } from '@backendkit-labs/idempotency';\nimport { createClient } from 'redis';\n\n// node-redis adapter\nconst redis = createClient({ url: process.env.REDIS_URL });\nawait redis.connect();\n\nIdempotencyModule.forRoot({\n  ttlSeconds: 86_400,\n  // Override the default InMemoryStore with Redis\n  // (inject the store via IDEMPOTENCY_STORE token in forRootAsync)\n})\n```\n\nFor full Redis store setup, use `forRootAsync` and inject your Redis client:\n\n```typescript\nimport { IdempotencyModule, RedisIdempotencyStore, IDEMPOTENCY_STORE } from '@backendkit-labs/idempotency';\n\n@Module({\n  imports: [\n    IdempotencyModule.forRootAsync({\n      imports:    [RedisModule],\n      inject:     [REDIS_CLIENT],\n      useFactory: (redisClient) => ({\n        ttlSeconds: 86_400,\n      }),\n    }),\n  ],\n  providers: [\n    {\n      provide:  IDEMPOTENCY_STORE,\n      inject:   [REDIS_CLIENT],\n      useFactory: (redisClient) => new RedisIdempotencyStore(redisClient),\n    },\n  ],\n})\nexport class AppModule {}\n```\n\n`RedisIdempotencyStore` expects a client that satisfies the minimal `RedisClient` interface:\n\n```typescript\ninterface RedisClient {\n  set(key: string, value: string, options: { nx: boolean; ex: number }): Promise<string | null>;\n  get(key: string): Promise<string | null>;\n  setex(key: string, seconds: number, value: string): Promise<unknown>;\n  del(key: string): Promise<unknown>;\n}\n```\n\nBoth `ioredis` and `node-redis` satisfy this interface.\n\n### Custom Store\n\nImplement the `IdempotencyStore` interface to plug in any persistence layer (DynamoDB, Postgres, Memcached):\n\n```typescript\nimport type { IdempotencyStore, IdempotencyRecord } from '@backendkit-labs/idempotency';\nimport { Injectable } from '@nestjs/common';\n\n@Injectable()\nexport class DynamoIdempotencyStore implements IdempotencyStore {\n  async setIfAbsent(record: IdempotencyRecord, ttlSeconds: number): Promise<IdempotencyRecord | null> {\n    // Attempt a conditional write — return null if inserted, existing record if key already present\n  }\n\n  async get(key: string): Promise<IdempotencyRecord | null> { ... }\n\n  async complete(key: string, statusCode: number, body: unknown, ttlSeconds: number): Promise<void> { ... }\n\n  async delete(key: string): Promise<void> { ... }\n}\n```\n\nThen register it via the `IDEMPOTENCY_STORE` token:\n\n```typescript\n{\n  provide:  IDEMPOTENCY_STORE,\n  useClass: DynamoIdempotencyStore,\n}\n```\n\n---\n\n## Error Reference\n\nAll errors are standard NestJS `HttpException` subclasses and are handled by NestJS's built-in exception filter.\n\n| Error | Status | When thrown |\n|-------|--------|------------|\n| `IdempotencyKeyMissingError` | `422` | The configured `keyHeader` is absent from the request |\n| `IdempotencyKeyInvalidError` | `422` | The key is present but not 1–256 printable ASCII characters |\n| `IdempotencyPendingConflictError` | `409` | A request with this key is already in-flight and `pendingStrategy` is `'reject'` |\n\n```typescript\n// Example 422 response body\n{\n  \"statusCode\": 422,\n  \"error\": \"Unprocessable Entity\",\n  \"message\": \"Missing required header: idempotency-key\"\n}\n\n// Example 409 response body\n{\n  \"statusCode\": 409,\n  \"error\": \"Conflict\",\n  \"message\": \"Request with idempotency key \\\"order-checkout-7f3a9b\\\" is still in progress\"\n}\n```\n\n---\n\n## Response Headers\n\n| Header | Value | When present |\n|--------|-------|-------------|\n| `Idempotent-Replayed` | `true` | The response was served from the store — the handler was NOT called |\n| `Retry-After` | `1` | Set on `202 Accepted` when `pendingStrategy: 'replay'` and the request is in-flight |\n\n---\n\n## Architecture\n\n```\nIdempotencyModule.forRoot()\n  ├── registers IdempotencyInterceptor as APP_INTERCEPTOR (global)\n  ├── provides InMemoryIdempotencyStore via IDEMPOTENCY_STORE token\n  └── provides Reflector (required for reading @Idempotent() metadata)\n\nIdempotencyInterceptor\n  ├── reads @Idempotent() metadata via Reflector — skips routes without it\n  ├── validates Idempotency-Key header (presence + format)\n  ├── builds composite key: METHOD:path:client-key\n  ├── store.get()         — check for existing record\n  ├── store.setIfAbsent() — atomic claim (first writer wins)\n  ├── next.handle()       — execute handler if key was claimed\n  ├── store.complete()    — persist response on success (awaited via mergeMap)\n  └── store.delete()      — release key on handler error (client can retry)\n\nIdempotencyStore (interface)\n  ├── InMemoryIdempotencyStore  — Map<string, Entry> with lazy TTL eviction\n  └── RedisIdempotencyStore     — SET NX EX (atomic, no GET+SET race)\n```\n\n---\n\n## License\n\nApache-2.0 — [BackendKit Labs](https://github.com/BackendKit-labs)\n","readmeFilename":"README.md"}