From a45eb28911a46eaf881e90d2f54007f276700ebf Mon Sep 17 00:00:00 2001 From: Mahendra Date: Wed, 11 Oct 2023 19:51:52 -0300 Subject: [PATCH] feat: add idempotency interceptor --- .cspell.json | 6 +- apps/api/e2e/idempotency.e2e.ts | 217 +++++++++++++++++ apps/api/src/.env.development | 2 + apps/api/src/.env.production | 2 + apps/api/src/.env.test | 2 + apps/api/src/app.module.ts | 8 +- .../framework/idempotency.interceptor.ts | 219 ++++++++++++++++++ .../src/app/testing/dtos/idempotency.dto.ts | 3 + .../api/src/app/testing/testing.controller.ts | 26 ++- apps/api/src/types/env.d.ts | 1 + .../src/services/cache/cache-service.spec.ts | 10 + .../src/services/cache/cache.service.ts | 16 ++ 12 files changed, 509 insertions(+), 3 deletions(-) create mode 100644 apps/api/e2e/idempotency.e2e.ts create mode 100644 apps/api/src/app/shared/framework/idempotency.interceptor.ts create mode 100644 apps/api/src/app/testing/dtos/idempotency.dto.ts diff --git a/.cspell.json b/.cspell.json index 6f667c50dac..e6f14df8fa9 100644 --- a/.cspell.json +++ b/.cspell.json @@ -510,7 +510,11 @@ "mediumdark", "Docgen", "clicksend", - "Clicksend" + "Clicksend", + "idempotency", + "IDEMPOTENCY", + "Idempotency", + "occured" ], "flagWords": [], "patterns": [ diff --git a/apps/api/e2e/idempotency.e2e.ts b/apps/api/e2e/idempotency.e2e.ts new file mode 100644 index 00000000000..97ff2c182a4 --- /dev/null +++ b/apps/api/e2e/idempotency.e2e.ts @@ -0,0 +1,217 @@ +import { UserSession } from '@novu/testing'; +import { expect } from 'chai'; +describe('Idempotency Test', async () => { + let session: UserSession; + const path = '/v1/testing/idempotency'; + const HEADER_KEYS = { + IDEMPOTENCY: 'idempotency-key', + RETRY_AFTER: 'retry-after', + IDEMPOTENCY_CONFLICT: 'x-idempotency-conflict', + }; + + describe('when enabled', () => { + before(async () => { + session = new UserSession(); + await session.initialize(); + process.env.API_IDEMPOTENCY_ENABLED = 'true'; + }); + + it('should return cached same response for duplicate requests', async () => { + const key = Math.random().toString(); + const { body, headers } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY, key) + .send({ data: 201 }) + .expect(201); + const { body: bodyDupe, headers: headerDupe } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY, key) + .send({ data: 201 }) + .expect(201); + expect(typeof body.data.number === 'number').to.be.true; + expect(body.data.number).to.equal(bodyDupe.data.number); + expect(headers[HEADER_KEYS.IDEMPOTENCY]).to.eq(key); + expect(headerDupe[HEADER_KEYS.IDEMPOTENCY]).to.eq(key); + }); + it('should return conflict when concurrent requests are made', async () => { + const key = Math.random().toString(); + const [{ headers, body, status }, { headers: headerDupe, body: bodyDupe, status: statusDupe }] = + await Promise.all([ + session.testAgent.post(path).set(HEADER_KEYS.IDEMPOTENCY, key).send({ data: 250 }), + session.testAgent.post(path).set(HEADER_KEYS.IDEMPOTENCY, key).send({ data: 250 }), + ]); + const oneSuccess = status === 201 || statusDupe === 201; + const oneConflict = status === 429 || statusDupe === 429; + const conflictBody = status === 201 ? bodyDupe : body; + const retryHeader = headers[HEADER_KEYS.RETRY_AFTER] || headerDupe[HEADER_KEYS.RETRY_AFTER]; + const conflictHeader = headers[HEADER_KEYS.IDEMPOTENCY_CONFLICT] || headerDupe[HEADER_KEYS.IDEMPOTENCY_CONFLICT]; + expect(oneSuccess).to.be.true; + expect(oneConflict).to.be.true; + expect(headers[HEADER_KEYS.IDEMPOTENCY]).to.eq(key); + expect(headerDupe[HEADER_KEYS.IDEMPOTENCY]).to.eq(key); + expect(retryHeader).to.eq(`1`); + expect(conflictHeader).to.eq('IDEMPOTENCY_REQUEST_PROCESSING'); + expect(JSON.stringify(conflictBody)).to.eq( + JSON.stringify({ + error: 'IDEMPOTENCY_REQUEST_PROCESSING', + message: `request ${key} is currently being processed. Please retry after 1 second`, + }) + ); + }); + it('should return conflict when different body is sent for same key', async () => { + const key = Math.random().toString(); + const { headers, body, status } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY, key) + .send({ data: 250 }); + const { + headers: headerDupe, + body: bodyDupe, + status: statusDupe, + } = await session.testAgent.post(path).set(HEADER_KEYS.IDEMPOTENCY, key).send({ data: 251 }); + + const oneSuccess = status === 201 || statusDupe === 201; + const oneConflict = status === 409 || statusDupe === 409; + const conflictBody = status === 201 ? bodyDupe : body; + const conflictHeader = headers[HEADER_KEYS.IDEMPOTENCY_CONFLICT] || headerDupe[HEADER_KEYS.IDEMPOTENCY_CONFLICT]; + expect(oneSuccess).to.be.true; + expect(oneConflict).to.be.true; + expect(headers[HEADER_KEYS.IDEMPOTENCY]).to.eq(key); + expect(headerDupe[HEADER_KEYS.IDEMPOTENCY]).to.eq(key); + expect(conflictHeader).to.eq('IDEMPOTENCY_BODY_CONFLICT'); + expect(JSON.stringify(conflictBody)).to.eq( + JSON.stringify({ + error: 'IDEMPOTENCY_BODY_CONFLICT', + message: `request ${key} is being reused for difefrent body`, + }) + ); + }); + it('should return non cached response for unique requests', async () => { + const key = Math.random().toString(); + const key1 = Math.random().toString(); + const { body, headers } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY, key) + .send({ data: 201 }) + .expect(201); + + const { body: bodyDupe, headers: headerDupe } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY, key1) + .send({ data: 201 }) + .expect(201); + expect(typeof body.data.number === 'number').to.be.true; + expect(typeof bodyDupe.data.number === 'number').to.be.true; + expect(body.data.number).not.to.equal(bodyDupe.data.number); + expect(headers[HEADER_KEYS.IDEMPOTENCY]).to.eq(key); + expect(headerDupe[HEADER_KEYS.IDEMPOTENCY]).to.eq(key1); + }); + it('should return non cached response for GET requests', async () => { + const key = Math.random().toString(); + const { body, headers } = await session.testAgent + .get(path) + .set(HEADER_KEYS.IDEMPOTENCY, key) + .send({}) + .expect(200); + + const { body: bodyDupe } = await session.testAgent + .get(path) + .set(HEADER_KEYS.IDEMPOTENCY, key) + .send({}) + .expect(200); + expect(typeof body.data.number === 'number').to.be.true; + expect(typeof bodyDupe.data.number === 'number').to.be.true; + expect(body.data.number).not.to.equal(bodyDupe.data.number); + expect(headers[HEADER_KEYS.IDEMPOTENCY]).to.eq(undefined); + }); + it('should return cached error response for duplicate requests', async () => { + const key = Math.random().toString(); + const { body, headers } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY, key) + .send({ data: 422 }) + .expect(422); + + const { body: bodyDupe, headers: headerDupe } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY, key) + .send({ data: 422 }) + .expect(422); + expect(JSON.stringify(body)).to.equal(JSON.stringify(bodyDupe)); + + expect(headers[HEADER_KEYS.IDEMPOTENCY]).to.eq(key); + expect(headerDupe[HEADER_KEYS.IDEMPOTENCY]).to.eq(key); + }); + }); + + describe('when disabled', () => { + before(async () => { + session = new UserSession(); + await session.initialize(); + process.env.API_IDEMPOTENCY_ENABLED = 'false'; + }); + + it('should not return cached same response for duplicate requests', async () => { + const key = Math.random().toString(); + const { body } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY, key) + .send({ data: 201 }) + .expect(201); + + const { body: bodyDupe } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY, key) + .send({ data: 201 }) + .expect(201); + expect(typeof body.data.number === 'number').to.be.true; + expect(body.data.number).not.to.equal(bodyDupe.data.number); + }); + it('should return non cached response for unique requests', async () => { + const key = Math.random().toString(); + const key1 = Math.random().toString(); + const { body } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY, key) + .send({ data: 201 }) + .expect(201); + + const { body: bodyDupe } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY, key1) + .send({ data: 201 }) + .expect(201); + expect(typeof body.data.number === 'number').to.be.true; + expect(typeof bodyDupe.data.number === 'number').to.be.true; + expect(body.data.number).not.to.equal(bodyDupe.data.number); + }); + it('should return non cached response for GET requests', async () => { + const key = Math.random().toString(); + const { body } = await session.testAgent.get(path).set(HEADER_KEYS.IDEMPOTENCY, key).send({}).expect(200); + + const { body: bodyDupe } = await session.testAgent + .get(path) + .set(HEADER_KEYS.IDEMPOTENCY, key) + .send({}) + .expect(200); + expect(typeof body.data.number === 'number').to.be.true; + expect(typeof bodyDupe.data.number === 'number').to.be.true; + expect(body.data.number).not.to.equal(bodyDupe.data.number); + }); + it('should not return cached error response for duplicate requests', async () => { + const key = Math.random().toString(); + const { body } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY, key) + .send({ data: 500 }) + .expect(500); + + const { body: bodyDupe } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY, key) + .send({ data: 500 }) + .expect(500); + expect(JSON.stringify(body)).not.to.equal(JSON.stringify(bodyDupe)); + }); + }); +}); diff --git a/apps/api/src/.env.development b/apps/api/src/.env.development index 8af859ce673..8faec5d51d4 100644 --- a/apps/api/src/.env.development +++ b/apps/api/src/.env.development @@ -64,3 +64,5 @@ NOVU_SMS_INTEGRATION_SENDER= INTERCOM_IDENTITY_VERIFICATION_SECRET_KEY= LAUNCH_DARKLY_SDK_KEY= + +API_IDEMPOTENCY_ENABLED=true diff --git a/apps/api/src/.env.production b/apps/api/src/.env.production index a048998f9d4..2e66ec67b0e 100644 --- a/apps/api/src/.env.production +++ b/apps/api/src/.env.production @@ -53,3 +53,5 @@ NOVU_SMS_INTEGRATION_SENDER= INTERCOM_IDENTITY_VERIFICATION_SECRET_KEY= LAUNCH_DARKLY_SDK_KEY= + +API_IDEMPOTENCY_ENABLED=true diff --git a/apps/api/src/.env.test b/apps/api/src/.env.test index 5b43216c298..301e6f52efd 100644 --- a/apps/api/src/.env.test +++ b/apps/api/src/.env.test @@ -90,3 +90,5 @@ MAX_NOVU_INTEGRATION_SMS_REQUESTS=20 NOVU_SMS_INTEGRATION_ACCOUNT_SID=test NOVU_SMS_INTEGRATION_TOKEN=test NOVU_SMS_INTEGRATION_SENDER=1234567890 + +API_IDEMPOTENCY_ENABLED=true diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 00418026038..05373ac753e 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -31,6 +31,7 @@ import { TopicsModule } from './app/topics/topics.module'; import { InboundParseModule } from './app/inbound-parse/inbound-parse.module'; import { BlueprintModule } from './app/blueprint/blueprint.module'; import { TenantModule } from './app/tenant/tenant.module'; +import { IdempotencyInterceptor } from './app/shared/framework/idempotency.interceptor'; const enterpriseImports = (): Array | ForwardReference> => { const modules: Array | ForwardReference> = []; @@ -78,7 +79,12 @@ const enterpriseModules = enterpriseImports(); const modules = baseModules.concat(enterpriseModules); -const providers: Provider[] = []; +const providers: Provider[] = [ + { + provide: APP_INTERCEPTOR, + useClass: IdempotencyInterceptor, + }, +]; if (process.env.SENTRY_DSN) { modules.push(RavenModule); diff --git a/apps/api/src/app/shared/framework/idempotency.interceptor.ts b/apps/api/src/app/shared/framework/idempotency.interceptor.ts new file mode 100644 index 00000000000..f4b3a28515e --- /dev/null +++ b/apps/api/src/app/shared/framework/idempotency.interceptor.ts @@ -0,0 +1,219 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, + Logger, + ConflictException, + HttpException, + InternalServerErrorException, +} from '@nestjs/common'; +import { CacheService } from '@novu/application-generic'; +import { Observable, of, throwError } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { createHash } from 'crypto'; + +const LOG_CONTEXT = 'IdempotencyInterceptor'; +const IDEMPOTENCY_CACHE_TTL = 60 * 60 * 6; //6h +const IDEMPOTENCY_PROGRESS_TTL = 60 * 5; //5min + +const HEADER_KEYS = { + IDEMPOTENCY: 'idempotency-key', + RETRY_AFTER: 'retry-after', + IDEMPOTENCY_CONFLICT: 'x-idempotency-conflict', +}; + +enum ReqStatusEnum { + PROGRESS = 'in-progress', + SUCCESS = 'success', + ERROR = 'error', +} + +@Injectable() +export class IdempotencyInterceptor implements NestInterceptor { + constructor(private readonly cacheService: CacheService) {} + + async intercept(context: ExecutionContext, next: CallHandler): Promise> { + const request = context.switchToHttp().getRequest(); + const idempotencyKey = this.getIdempotencyKey(context); + const isEnabled = process.env.API_IDEMPOTENCY_ENABLED == 'true'; + if (!isEnabled || !idempotencyKey || !['post', 'patch'].includes(request.method.toLowerCase())) { + return next.handle(); + } + const cacheKey = this.getCacheKey(context); + + try { + const bodyHash = this.hashRequestBody(request.body); + //if 1st time we are seeing the request, marks the request as in-progress if not, does nothing + const isNewReq = await this.setCache( + cacheKey, + { status: ReqStatusEnum.PROGRESS, bodyHash }, + IDEMPOTENCY_PROGRESS_TTL, + true + ); + // Check if the idempotency key is in the cache + if (isNewReq) { + return await this.handleNewRequest(context, next, bodyHash); + } else { + return await this.handlerDuplicateRequest(context, bodyHash); + } + } catch (err) { + Logger.warn( + `An error occurred while making idempotency check, proceeding without idempotency. key:${idempotencyKey}. error: ${err.message}`, + LOG_CONTEXT + ); + } + + //something unexpected happened, both cached response and handler did not execute as expected,so proceed w/o idempotency + return next.handle(); + } + + private getIdempotencyKey(context: ExecutionContext): string | undefined { + const request = context.switchToHttp().getRequest(); + + return request.headers[HEADER_KEYS.IDEMPOTENCY]; + } + + private getCacheKey(context: ExecutionContext): string { + const request = context.switchToHttp().getRequest(); + const { organizationId } = request.user || {}; + const env = process.env.NODE_ENV; + + return `${env}-${organizationId}-${this.getIdempotencyKey(context)}`; + } + + async setCache( + key: string, + val: { status: ReqStatusEnum; bodyHash: string; data?: any; statusCode?: number }, + ttl: number, + ifNotExists?: boolean + ): Promise { + try { + if (ifNotExists) { + return await this.cacheService.setIfNotExist(key, JSON.stringify(val), { ttl }); + } + await this.cacheService.set(key, JSON.stringify(val), { ttl }); + } catch (err) { + Logger.warn(`An error occurred while setting idempotency cache, key:${key} error: ${err.message}`, LOG_CONTEXT); + } + + return null; + } + + private buildError(error: any): HttpException { + const statusCode = error.status || error.response?.statusCode || 500; + if (statusCode == 500 && !error.response) { + //some unhandled exception occured + return new InternalServerErrorException(); + } + + return new HttpException(error.response || error.message, statusCode, error.response?.options); + } + + private setHeaders(response: any, headers: Record) { + Object.keys(headers).map((key) => { + if (headers[key]) { + response.set(key, headers[key]); + } + }); + } + + private hashRequestBody(body: object): string { + const hash = createHash('blake2s256'); + hash.update(Buffer.from(JSON.stringify(body))); + + return hash.digest('hex'); + } + + private async handlerDuplicateRequest(context: ExecutionContext, bodyHash: string): Promise> { + const cacheKey = this.getCacheKey(context); + const idempotencyKey = this.getIdempotencyKey(context)!; + const data = await this.cacheService.get(cacheKey); + this.setHeaders(context.switchToHttp().getResponse(), { [HEADER_KEYS.IDEMPOTENCY]: idempotencyKey }); + const parsed = JSON.parse(data); + if (parsed.status === ReqStatusEnum.PROGRESS) { + // api call is in progress, so client need to handle this case + Logger.error(`previous api call in progress rejecting the request. key:${idempotencyKey}`, LOG_CONTEXT); + const error = 'IDEMPOTENCY_REQUEST_PROCESSING'; + this.setHeaders(context.switchToHttp().getResponse(), { + [HEADER_KEYS.IDEMPOTENCY_CONFLICT]: error, + [HEADER_KEYS.RETRY_AFTER]: `1`, + }); + + return throwError( + () => + new HttpException( + { + error, + message: `request ${idempotencyKey} is currently being processed. Please retry after 1 second`, + }, + 429 + ) + ); + } + if (bodyHash !== parsed.bodyHash) { + //different body sent than before + Logger.error(`idempotency key is being reused for different bodies. key:${idempotencyKey}`, LOG_CONTEXT); + const error = 'IDEMPOTENCY_BODY_CONFLICT'; + this.setHeaders(context.switchToHttp().getResponse(), { [HEADER_KEYS.IDEMPOTENCY_CONFLICT]: error }); + + return throwError( + () => new ConflictException({ error, message: `request ${idempotencyKey} is being reused for difefrent body` }) + ); + } + //already seen the request return cached response + if (parsed.status === ReqStatusEnum.ERROR) { + Logger.error(`returning cached error response. key:${idempotencyKey}`, LOG_CONTEXT); + + return throwError(() => this.buildError(parsed.data)); + } + + return of(parsed.data); + } + + private async handleNewRequest( + context: ExecutionContext, + next: CallHandler, + bodyHash: string + ): Promise> { + const cacheKey = this.getCacheKey(context); + const idempotencyKey = this.getIdempotencyKey(context)!; + + return next.handle().pipe( + map(async (response) => { + const httpResponse = context.switchToHttp().getResponse(); + const statusCode = httpResponse.statusCode; + + // Cache the success response and return it + await this.setCache( + cacheKey, + { status: ReqStatusEnum.SUCCESS, bodyHash, statusCode: statusCode, data: response }, + IDEMPOTENCY_CACHE_TTL + ); + Logger.verbose(`cached the success response for idempotency key:${idempotencyKey}`, LOG_CONTEXT); + this.setHeaders(httpResponse, { [HEADER_KEYS.IDEMPOTENCY]: idempotencyKey }); + + return response; + }), + catchError((err) => { + const httpException = this.buildError(err); + // Cache the error response and return it + const error = err instanceof HttpException ? err : httpException; + this.setCache( + cacheKey, + { + status: ReqStatusEnum.ERROR, + statusCode: httpException.getStatus(), + bodyHash, + data: error, + }, + IDEMPOTENCY_CACHE_TTL + ).catch(() => {}); + Logger.verbose(`cached the error response for idempotency`, LOG_CONTEXT); + this.setHeaders(context.switchToHttp().getResponse(), { [HEADER_KEYS.IDEMPOTENCY]: idempotencyKey }); + + return throwError(() => error); + }) + ); + } +} diff --git a/apps/api/src/app/testing/dtos/idempotency.dto.ts b/apps/api/src/app/testing/dtos/idempotency.dto.ts new file mode 100644 index 00000000000..6f885116da1 --- /dev/null +++ b/apps/api/src/app/testing/dtos/idempotency.dto.ts @@ -0,0 +1,3 @@ +export class IdempotencyBodyDto { + data: number; +} diff --git a/apps/api/src/app/testing/testing.controller.ts b/apps/api/src/app/testing/testing.controller.ts index f23c67f0e76..bb9725de86f 100644 --- a/apps/api/src/app/testing/testing.controller.ts +++ b/apps/api/src/app/testing/testing.controller.ts @@ -1,7 +1,9 @@ -import { Body, Controller, NotFoundException, Post } from '@nestjs/common'; +import { Body, Controller, Get, HttpException, NotFoundException, Post } from '@nestjs/common'; import { DalService } from '@novu/dal'; import { IUserEntity } from '@novu/shared'; import { ISeedDataResponseDto, SeedDataBodyDto } from './dtos/seed-data.dto'; +import { IdempotencyBodyDto } from './dtos/idempotency.dto'; + import { SeedData } from './usecases/seed-data/seed-data.usecase'; import { SeedDataCommand } from './usecases/seed-data/seed-data.command'; import { CreateSession } from './usecases/create-session/create-session.usecase'; @@ -47,4 +49,26 @@ export class TestingController { return await this.seedDataUsecase.execute(command); } + + @Post('/idempotency') + async idempotency(@Body() body: IdempotencyBodyDto): Promise<{ number: number }> { + if (process.env.NODE_ENV !== 'test') throw new NotFoundException(); + + if (body.data > 300) { + throw new HttpException(`` + Math.random(), body.data); + } + if (body.data === 250) { + //for testing conflict + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + return { number: Math.random() }; + } + + @Get('/idempotency') + async idempotencyGet(): Promise<{ number: number }> { + if (process.env.NODE_ENV !== 'test') throw new NotFoundException(); + + return { number: Math.random() }; + } } diff --git a/apps/api/src/types/env.d.ts b/apps/api/src/types/env.d.ts index d5650e5ab3c..f48a79d75de 100644 --- a/apps/api/src/types/env.d.ts +++ b/apps/api/src/types/env.d.ts @@ -11,6 +11,7 @@ declare namespace NodeJS { NODE_ENV: 'test' | 'production' | 'dev' | 'ci' | 'local'; PORT: string; DISABLE_USER_REGISTRATION: 'true' | 'false'; + API_IDEMPOTENCY_ENABLED: 'true' | 'false'; FRONT_BASE_URL: string; SENTRY_DSN: string; } diff --git a/packages/application-generic/src/services/cache/cache-service.spec.ts b/packages/application-generic/src/services/cache/cache-service.spec.ts index 573f9a57452..545bf45b548 100644 --- a/packages/application-generic/src/services/cache/cache-service.spec.ts +++ b/packages/application-generic/src/services/cache/cache-service.spec.ts @@ -113,6 +113,16 @@ describe('Cache Service - Cluster Mode', () => { expect(value).toBe('value1'); }); + it('should be able to add a key / value in the Redis Cluster if key not exist', async () => { + const result = await cacheService.setIfNotExist('key1-not-exist', 'value1'); + expect(result).toBeDefined(); + const result1 = await cacheService.setIfNotExist( + 'key1-not-exist', + 'value1' + ); + expect(result1).toBeFalsy(); + }); + it('should be able to delete a key / value in the Redis Cluster', async () => { const result = await cacheService.del('key1'); expect(result).toBe(1); diff --git a/packages/application-generic/src/services/cache/cache.service.ts b/packages/application-generic/src/services/cache/cache.service.ts index 03fa14518bf..3623d420bde 100644 --- a/packages/application-generic/src/services/cache/cache.service.ts +++ b/packages/application-generic/src/services/cache/cache.service.ts @@ -84,6 +84,22 @@ export class CacheService implements ICacheService { return result; } + public async setIfNotExist( + key: string, + value: string, + options?: CachingConfig + ): Promise { + const result = await this.client?.set( + key, + value, + 'EX', + this.getTtlInSeconds(options), + 'NX' + ); + + return result; + } + public async setQuery( key: string, value: string,