From adc94b3783cad07950b2826abf31ccb63641506e Mon Sep 17 00:00:00 2001 From: Roman Kabanov <4685931+unicoder88@users.noreply.github.com> Date: Mon, 25 Aug 2025 12:36:11 +0200 Subject: [PATCH 1/3] Don't return cached errors, allow to retry --- packages/core/src/idempotency.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/core/src/idempotency.ts b/packages/core/src/idempotency.ts index e7034bc..68ace50 100644 --- a/packages/core/src/idempotency.ts +++ b/packages/core/src/idempotency.ts @@ -142,15 +142,19 @@ export class Idempotency { "A request is outstanding for this Idempotency-Key", IdempotencyErrorCodes.REQUEST_IN_PROGRESS, ); - } else { - if (fingerPrint !== data.fingerPrint) { - throw new IdempotencyError( - "Idempotency-Key is already used", - IdempotencyErrorCodes.IDEMPOTENCY_FINGERPRINT_MISSMATCH, - ); - } - return data.response; } + if (fingerPrint !== data.fingerPrint) { + throw new IdempotencyError( + "Idempotency-Key is already used", + IdempotencyErrorCodes.IDEMPOTENCY_FINGERPRINT_MISSMATCH, + ); + } + if (data.response?.error) { + // don't return cached uncaught errors, allow to retry + return undefined + } + + return data.response; } } } From d69b7995dfe917117b33def099cec3bff7f71597 Mon Sep 17 00:00:00 2001 From: Roman Kabanov <4685931+unicoder88@users.noreply.github.com> Date: Mon, 8 Sep 2025 09:57:28 +0200 Subject: [PATCH 2/3] Add config to skip caching errors, add delete method to storage adapter --- packages/core/src/idempotency.ts | 28 +++++++++++-------- packages/core/src/types.ts | 6 ++++ .../node-idempotency.interceptor.ts | 6 ++++ .../src/adapter-memory.ts | 4 +++ .../src/adapter-redis.ts | 4 +++ packages/storage/src/types.ts | 1 + 6 files changed, 37 insertions(+), 12 deletions(-) diff --git a/packages/core/src/idempotency.ts b/packages/core/src/idempotency.ts index 68ace50..ec3167c 100644 --- a/packages/core/src/idempotency.ts +++ b/packages/core/src/idempotency.ts @@ -142,19 +142,15 @@ export class Idempotency { "A request is outstanding for this Idempotency-Key", IdempotencyErrorCodes.REQUEST_IN_PROGRESS, ); + } else { + if (fingerPrint !== data.fingerPrint) { + throw new IdempotencyError( + "Idempotency-Key is already used", + IdempotencyErrorCodes.IDEMPOTENCY_FINGERPRINT_MISSMATCH, + ); + } + return data.response; } - if (fingerPrint !== data.fingerPrint) { - throw new IdempotencyError( - "Idempotency-Key is already used", - IdempotencyErrorCodes.IDEMPOTENCY_FINGERPRINT_MISSMATCH, - ); - } - if (data.response?.error) { - // don't return cached uncaught errors, allow to retry - return undefined - } - - return data.response; } } } @@ -181,4 +177,12 @@ export class Idempotency { }); } } + + async clearCache(req: IdempotencyParams): Promise { + const reqInternal = this.getInternalRequest(req); + if (await this.isEnabled(reqInternal)) { + const cacheKey = this.getIdempotencyCacheKey(reqInternal); + await this.storage.delete(cacheKey); + } + } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6478bef..2c49862 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -30,6 +30,12 @@ export interface IdempotencyOptions { * if set to `true` requests without idempotency key header will be rejected */ enforceIdempotency?: boolean; + /** + * @defaultValue `false` + * + * if set to `true` responses with errors will not be cached + */ + skipErrorsCache?: boolean; /** * @defaultValue `undefined` diff --git a/packages/plugin-nestjs/src/interceptors/node-idempotency.interceptor.ts b/packages/plugin-nestjs/src/interceptors/node-idempotency.interceptor.ts index 26c6517..fa45794 100644 --- a/packages/plugin-nestjs/src/interceptors/node-idempotency.interceptor.ts +++ b/packages/plugin-nestjs/src/interceptors/node-idempotency.interceptor.ts @@ -184,6 +184,12 @@ export class NodeIdempotencyInterceptor implements NestInterceptor { return response; }), catchError((err) => { + if (idempotencyReq.options?.skipErrorsCache) { + // do not cache the error itself, clear the cache key and allow subsequent requests to retry + this.nodeIdempotency.clearCache(idempotencyReq).catch(() => {}); + throw err; + } + const httpException = this.buildError(err as SerializedAPIException); const error = err instanceof HttpException ? err : httpException; const res: IdempotencyResponse = { diff --git a/packages/storage-adapter-memory/src/adapter-memory.ts b/packages/storage-adapter-memory/src/adapter-memory.ts index e1af4b0..314499c 100644 --- a/packages/storage-adapter-memory/src/adapter-memory.ts +++ b/packages/storage-adapter-memory/src/adapter-memory.ts @@ -47,4 +47,8 @@ export class MemoryStorageAdapter implements StorageAdapter { } return val?.item; } + + async delete(key: string): Promise { + this.cache.delete(key); + } } diff --git a/packages/storage-adapter-redis/src/adapter-redis.ts b/packages/storage-adapter-redis/src/adapter-redis.ts index a9fe84e..7d00c10 100644 --- a/packages/storage-adapter-redis/src/adapter-redis.ts +++ b/packages/storage-adapter-redis/src/adapter-redis.ts @@ -50,4 +50,8 @@ export class RedisStorageAdapter implements StorageAdapter { const val = await this.client.get(key); return val ?? undefined; } + + async delete(key: string): Promise { + await this.client.del(key); + } } diff --git a/packages/storage/src/types.ts b/packages/storage/src/types.ts index f5888ca..4cf9e06 100644 --- a/packages/storage/src/types.ts +++ b/packages/storage/src/types.ts @@ -6,6 +6,7 @@ export interface StorageAdapter { ) => Promise; set: (key: string, val: string, { ttl }: { ttl?: number }) => Promise; get: (key: string) => Promise; + delete: (key: string) => Promise; connect?: () => Promise; disconnect?: () => Promise; } From d2ad0ec1afc3e69307b46ab169b5c0b53067e6a2 Mon Sep 17 00:00:00 2001 From: Roman Kabanov <4685931+unicoder88@users.noreply.github.com> Date: Mon, 15 Sep 2025 09:09:53 +0200 Subject: [PATCH 3/3] Move error handling to code so that every adapter will get the feature --- packages/core/src/idempotency.ts | 14 ++++++-------- .../interceptors/node-idempotency.interceptor.ts | 6 ------ 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/core/src/idempotency.ts b/packages/core/src/idempotency.ts index ec3167c..3f6fa96 100644 --- a/packages/core/src/idempotency.ts +++ b/packages/core/src/idempotency.ts @@ -167,6 +167,12 @@ export class Idempotency { if (await this.isEnabled(reqInternal)) { const fingerPrint = this.getFingerPrint(reqInternal); const cacheKey = this.getIdempotencyCacheKey(reqInternal); + + if (res.error && req.options?.skipErrorsCache) { + // do not cache the error itself, clear the cache key and allow subsequent requests to retry + await this.storage.delete(cacheKey); + } + const payload: StoragePayload = { status: RequestStatusEnum.COMPLETE, fingerPrint, @@ -177,12 +183,4 @@ export class Idempotency { }); } } - - async clearCache(req: IdempotencyParams): Promise { - const reqInternal = this.getInternalRequest(req); - if (await this.isEnabled(reqInternal)) { - const cacheKey = this.getIdempotencyCacheKey(reqInternal); - await this.storage.delete(cacheKey); - } - } } diff --git a/packages/plugin-nestjs/src/interceptors/node-idempotency.interceptor.ts b/packages/plugin-nestjs/src/interceptors/node-idempotency.interceptor.ts index fa45794..26c6517 100644 --- a/packages/plugin-nestjs/src/interceptors/node-idempotency.interceptor.ts +++ b/packages/plugin-nestjs/src/interceptors/node-idempotency.interceptor.ts @@ -184,12 +184,6 @@ export class NodeIdempotencyInterceptor implements NestInterceptor { return response; }), catchError((err) => { - if (idempotencyReq.options?.skipErrorsCache) { - // do not cache the error itself, clear the cache key and allow subsequent requests to retry - this.nodeIdempotency.clearCache(idempotencyReq).catch(() => {}); - throw err; - } - const httpException = this.buildError(err as SerializedAPIException); const error = err instanceof HttpException ? err : httpException; const res: IdempotencyResponse = {