From 93c1856443c88cb0eea134f264dcf3395c6a757f Mon Sep 17 00:00:00 2001 From: sadeeq6400 Date: Tue, 21 Jul 2026 22:53:03 +0100 Subject: [PATCH] Implement Error Handling & Recovery Module --- .../src/decorators/transactional.decorator.ts | 6 + .../src/filters/http-exception.filter.ts | 29 +- .../interceptors/transaction.interceptor.ts | 40 + package-lock.json | 989 +++++++++++++++++- package.json | 7 + src/app.controller.ts | 12 +- src/app.module.ts | 8 +- src/main.ts | 9 +- .../error-handler/error-handler.module.ts | 8 + .../error-handler.service.spec.ts | 51 + .../error-handler/error-handler.service.ts | 37 + src/modules/error-handler/errors.ts | 91 ++ src/modules/queue/dlq.service.ts | 20 + src/modules/queue/queue.module.ts | 25 + src/modules/resilience/resilience.module.ts | 14 + .../resilience/resilience.service.spec.ts | 71 ++ src/modules/resilience/resilience.service.ts | 61 ++ 17 files changed, 1440 insertions(+), 38 deletions(-) create mode 100644 libs/common/src/decorators/transactional.decorator.ts create mode 100644 libs/common/src/interceptors/transaction.interceptor.ts create mode 100644 src/modules/error-handler/error-handler.module.ts create mode 100644 src/modules/error-handler/error-handler.service.spec.ts create mode 100644 src/modules/error-handler/error-handler.service.ts create mode 100644 src/modules/error-handler/errors.ts create mode 100644 src/modules/queue/dlq.service.ts create mode 100644 src/modules/queue/queue.module.ts create mode 100644 src/modules/resilience/resilience.module.ts create mode 100644 src/modules/resilience/resilience.service.spec.ts create mode 100644 src/modules/resilience/resilience.service.ts diff --git a/libs/common/src/decorators/transactional.decorator.ts b/libs/common/src/decorators/transactional.decorator.ts new file mode 100644 index 0000000..e8db01e --- /dev/null +++ b/libs/common/src/decorators/transactional.decorator.ts @@ -0,0 +1,6 @@ +import { UseInterceptors } from '@nestjs/common'; +import { TransactionInterceptor } from '../interceptors/transaction.interceptor'; + +export function Transactional(): MethodDecorator { + return UseInterceptors(TransactionInterceptor); +} diff --git a/libs/common/src/filters/http-exception.filter.ts b/libs/common/src/filters/http-exception.filter.ts index ca0a626..003c107 100644 --- a/libs/common/src/filters/http-exception.filter.ts +++ b/libs/common/src/filters/http-exception.filter.ts @@ -4,9 +4,9 @@ import { ExceptionFilter, HttpException, HttpStatus, - Logger, } from '@nestjs/common'; import { Request, Response } from 'express'; +import { ErrorHandlerService } from 'src/modules/error-handler/error-handler.service'; /** * Normalizes error responses into the same envelope style used for success @@ -14,35 +14,24 @@ import { Request, Response } from 'express'; */ @Catch() export class AllExceptionsFilter implements ExceptionFilter { - private readonly logger = new Logger(AllExceptionsFilter.name); + constructor(private readonly errorHandlerService: ErrorHandlerService) {} catch(exception: unknown, host: ArgumentsHost): void { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const request = ctx.getRequest(); - const status = - exception instanceof HttpException - ? exception.getStatus() - : HttpStatus.INTERNAL_SERVER_ERROR; + const appError = this.errorHandlerService.handle(exception); - const message = - exception instanceof HttpException - ? exception.getResponse() - : 'Internal server error'; + const isProduction = process.env.NODE_ENV === 'production'; - if (status === HttpStatus.INTERNAL_SERVER_ERROR) { - this.logger.error( - `Unhandled exception on ${request.method} ${request.url}`, - exception instanceof Error ? exception.stack : String(exception), - ); - } - - response.status(status).json({ + response.status(appError.getStatus()).json({ success: false, - statusCode: status, + statusCode: appError.getStatus(), path: request.url, - error: typeof message === 'string' ? { message } : message, + error: isProduction + ? { message: 'An unexpected error occurred.' } + : appError.toDetail(), timestamp: new Date().toISOString(), }); } diff --git a/libs/common/src/interceptors/transaction.interceptor.ts b/libs/common/src/interceptors/transaction.interceptor.ts new file mode 100644 index 0000000..f1ca0f0 --- /dev/null +++ b/libs/common/src/interceptors/transaction.interceptor.ts @@ -0,0 +1,40 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { InjectConnection } from '@nestjs/typeorm'; +import { Connection } from 'typeorm'; +import { Observable } from 'rxjs'; +import { catchError, tap } from 'rxjs/operators'; + +@Injectable() +export class TransactionInterceptor implements NestInterceptor { + constructor(@InjectConnection() private readonly connection: Connection) {} + + async intercept( + context: ExecutionContext, + next: CallHandler, + ): Promise> { + const queryRunner = this.connection.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + // Attach the queryRunner to the request object + const request = context.switchToHttp().getRequest(); + request.queryRunner = queryRunner; + + return next.handle().pipe( + tap(async () => { + await queryRunner.commitTransaction(); + await queryRunner.release(); + }), + catchError(async (err) => { + await queryRunner.rollbackTransaction(); + await queryRunner.release(); + throw err; + }), + ); + } +} diff --git a/package-lock.json b/package-lock.json index dd4448f..b79b6d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "Apache-2.0", "dependencies": { + "@nestjs/bull": "^11.0.4", "@nestjs/common": "^10.4.4", "@nestjs/config": "^3.2.3", "@nestjs/core": "^10.4.4", @@ -19,6 +20,7 @@ "@nestjs/typeorm": "^10.0.2", "@stellar/stellar-sdk": "^12.3.0", "bcryptjs": "^2.4.3", + "bull": "^4.16.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "helmet": "^7.1.0", @@ -27,6 +29,11 @@ "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pg": "^8.12.0", + "polly-ts": "^0.1.0", + "polly-ts-core": "^0.1.1", + "polly-ts-nestjs": "^0.1.2", + "polly-ts-redis": "^0.1.2", + "polly-ts-telemetry": "^0.1.2", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "typeorm": "^0.3.20" @@ -907,6 +914,151 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/proxy-addr/node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 10" + } + }, "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", @@ -1644,6 +1796,112 @@ "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==", "license": "MIT" }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@nestjs/bull": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/bull/-/bull-11.0.4.tgz", + "integrity": "sha512-QVz2PR/rJF/isy7otVnMTSqLf/O71p9Ka7lBZt9Gm+NQFv8fcH2L11GL7TA0whyCcw/kAX5iRepUXz/wed4JoA==", + "license": "MIT", + "dependencies": { + "@nestjs/bull-shared": "^11.0.4", + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "bull": "^3.3 || ^4.0.0" + } + }, + "node_modules/@nestjs/bull-shared": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-11.0.4.tgz", + "integrity": "sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/cli": { "version": "10.4.9", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz", @@ -2099,6 +2357,16 @@ "npm": ">=5.0.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -2109,6 +2377,13 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT", + "peer": true + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -3078,6 +3353,13 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT", + "peer": true + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -3157,7 +3439,6 @@ "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -3354,6 +3635,16 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -3369,6 +3660,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/avvio": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz", + "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, "node_modules/axios": { "version": "1.18.1", "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", @@ -3802,6 +4114,34 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/bull": { + "version": "4.16.5", + "resolved": "https://registry.npmjs.org/bull/-/bull-4.16.5.tgz", + "integrity": "sha512-lDsx2BzkKe7gkCYiT5Acj02DpTwDznl/VNN7Psn7M3USPG7Vs/BaClZJJTAG+ufAR9++N1/NiUTdaFBWDIl5TQ==", + "license": "MIT", + "dependencies": { + "cron-parser": "^4.9.0", + "get-port": "^5.1.1", + "ioredis": "^5.3.2", + "lodash": "^4.17.21", + "msgpackr": "^1.11.2", + "semver": "^7.5.2", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/bull/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -4359,6 +4699,18 @@ "devOptional": true, "license": "MIT" }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4484,6 +4836,16 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -4494,6 +4856,16 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -5198,11 +5570,17 @@ "node": ">=4" } }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT", + "peer": true + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-diff": { @@ -5219,6 +5597,66 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/fast-json-stringify/node_modules/fast-uri": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.1.tgz", + "integrity": "sha512-YPOs1zD5TG2+EZt+r88LwF6mclA7TPkpwMP7ZN3TO2HiHS8TXvq7QA/17iJsV9dubcLo/f8eEYqMBruyQV21hQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", @@ -5226,17 +5664,77 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/fastify": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.10.0.tgz", + "integrity": "sha512-A9L0ziuWGQHgEEVgF3davQ9vbD93IuX+lo2IsxapQmu5b/Y/ynn9m9K5JHt9dvyJXOFc5iN0Zk5GHEOqnzhWjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -5379,6 +5877,21 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/find-my-way": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", + "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -5757,6 +6270,18 @@ "node": ">=8.0.0" } }, + "node_modules/get-port": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", + "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -6008,7 +6533,17 @@ "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", "license": "MIT", "engines": { - "node": ">=16.0.0" + "node": ">=16.0.0" + } + }, + "node_modules/hono": { + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" } }, "node_modules/html-escaper": { @@ -7286,11 +7821,30 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -7426,6 +7980,59 @@ "integrity": "sha512-80xal1m93rADejw2pMp2MSzFhHCPLEspjHxnH2UtqI+DgAmElsbmLMiqk9niwH9NWAfjsRtaJI+qBrOEmRx9nQ==", "license": "MIT" }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "peer": true + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -7552,6 +8159,15 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.8", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", @@ -7892,6 +8508,37 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msgpackr": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.12.1.tgz", + "integrity": "sha512-4EUH9tQHnMmEgzW/MdAP0KIfa1T9AF+htl0ffe2n5vb2EKn9y2co8ccpgWko6S52Jy1PQZKwRnx5/KkYjtd9MQ==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, "node_modules/multer": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", @@ -7977,6 +8624,21 @@ } } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -8038,6 +8700,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -8441,6 +9113,46 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "peer": true, + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT", + "peer": true + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -8530,6 +9242,121 @@ "node": ">=4" } }, + "node_modules/polly-ts": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/polly-ts/-/polly-ts-0.1.0.tgz", + "integrity": "sha512-ZnEWe6W6QlhJTA0ZwNFy6B24Gd5MIaR3qMxy1dYtC2jp+eQuivBH1HD407FgAWWUfFwi8GmfzSzV/qEkXB4cSQ==", + "license": "MIT", + "dependencies": { + "polly-ts-core": "0.1.1", + "polly-ts-express": "0.1.2", + "polly-ts-fastify": "0.1.2", + "polly-ts-hono": "0.1.2", + "polly-ts-http": "0.1.1", + "polly-ts-nestjs": "0.1.2", + "polly-ts-redis": "0.1.2", + "polly-ts-telemetry": "0.1.2", + "polly-ts-testing": "0.1.4" + } + }, + "node_modules/polly-ts-core": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/polly-ts-core/-/polly-ts-core-0.1.1.tgz", + "integrity": "sha512-ujGtIL9hRP7wNSuroxY7+j87yCqtIalENFzIn2VhYQmduj/EqJaK0ig2/5Bx8A6sBk/5IQ/ZeOZDt08ra34/Gw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/polly-ts-express": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/polly-ts-express/-/polly-ts-express-0.1.2.tgz", + "integrity": "sha512-GV4w+gt+fLoEkPHDcwHgybwPPPYF1DdINkzXEwFlRWzzXswtvrLE2HiogYgwliKWnBbMKBGtafqJJb6zoUb2/Q==", + "license": "MIT", + "dependencies": { + "polly-ts-core": "0.1.1" + }, + "peerDependencies": { + "express": "^4.0.0" + } + }, + "node_modules/polly-ts-fastify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/polly-ts-fastify/-/polly-ts-fastify-0.1.2.tgz", + "integrity": "sha512-o9X8v+sI7Wj62uk9VJxPjTo75EdO4X1lHrNzpSc4mJGE/nqZeq/sPvI6wqigLqpvbc81zY+597AI3mx+3t7r8A==", + "license": "MIT", + "dependencies": { + "polly-ts-core": "0.1.1" + }, + "peerDependencies": { + "fastify": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/polly-ts-hono": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/polly-ts-hono/-/polly-ts-hono-0.1.2.tgz", + "integrity": "sha512-t3TQVbDMp7K/xwYLOYAYMdCGD2cyyPb0eXZExrKqPnfBgqlwQJdTpsPmdN/eqLtCqbhoADkkPvAjtKZ7Xqu7zw==", + "license": "MIT", + "dependencies": { + "polly-ts-core": "0.1.1" + }, + "peerDependencies": { + "hono": "^3.0.0 || ^4.0.0" + } + }, + "node_modules/polly-ts-http": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/polly-ts-http/-/polly-ts-http-0.1.1.tgz", + "integrity": "sha512-6r47Ow7SHGp4c6kcLExt6lWFT33kcQvNkVyjqaGsypQkHJ5XDYOigadGCQA//aA0bKWSEzj2gEbyZUzxbpvGkQ==", + "license": "MIT", + "dependencies": { + "polly-ts-core": "0.1.1" + } + }, + "node_modules/polly-ts-nestjs": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/polly-ts-nestjs/-/polly-ts-nestjs-0.1.2.tgz", + "integrity": "sha512-36Gs+cX98uF9/e772x44jFY3g/r8TY1slmA8yOLPszKL7fETFhVJSYZV+LvNOLoocNFduFcTDvs6DfXFV99ULQ==", + "license": "MIT", + "dependencies": { + "polly-ts-core": "0.1.1" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", + "rxjs": "^7.0.0" + } + }, + "node_modules/polly-ts-redis": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/polly-ts-redis/-/polly-ts-redis-0.1.2.tgz", + "integrity": "sha512-ovAo5bMfVP4Xkh3YEdB8i8sikvRid6WmN5IyjvDYRF/gjzZ1v31YZRNIBqqLNBCyoi27a3mMY2oEhTh8aQMD9Q==", + "license": "MIT", + "dependencies": { + "ioredis": "^5.0.0", + "polly-ts-core": "0.1.1" + } + }, + "node_modules/polly-ts-telemetry": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/polly-ts-telemetry/-/polly-ts-telemetry-0.1.2.tgz", + "integrity": "sha512-Pf/mX8clTIiG5doAIu2HvTk0+cJL4jaYF4BFljydgtLVHBSwbC2NXRU3RiTM8jk5lXdkZwoW8BNhSZeOitEYdg==", + "license": "MIT", + "dependencies": { + "polly-ts-core": "0.1.1" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/polly-ts-testing": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/polly-ts-testing/-/polly-ts-testing-0.1.4.tgz", + "integrity": "sha512-FSBCWJLy5gw8njmjYnzHd2VGW+SRLmgZ4dVcHbxwDx6Fs8PmRX1s/VxYxxSphqf+WgPwkvIHKuQX7CgRlJoROw==", + "dependencies": { + "polly-ts-core": "0.1.1" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -8645,6 +9472,23 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "peer": true + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -8685,7 +9529,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8744,6 +9587,13 @@ ], "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT", + "peer": true + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -8824,6 +9674,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", @@ -8887,7 +9747,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8979,17 +9838,33 @@ "dev": true, "license": "ISC" }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT", + "peer": true + }, "node_modules/rimraf": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", @@ -9118,6 +9993,39 @@ ], "license": "MIT" }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -9177,6 +10085,23 @@ "dev": true, "license": "MIT" }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -9243,6 +10168,13 @@ "node": ">= 0.8.0" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT", + "peer": true + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -9418,6 +10350,16 @@ "require-addon": "^1.1.0" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -9996,6 +10938,26 @@ "dev": true, "license": "MIT" }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT", + "peer": true + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -10080,6 +11042,16 @@ "node": ">=8.0" } }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -10713,7 +11685,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" diff --git a/package.json b/package.json index f879d93..769838f 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@nestjs/bull": "^11.0.4", "@nestjs/common": "^10.4.4", "@nestjs/config": "^3.2.3", "@nestjs/core": "^10.4.4", @@ -30,6 +31,7 @@ "@nestjs/typeorm": "^10.0.2", "@stellar/stellar-sdk": "^12.3.0", "bcryptjs": "^2.4.3", + "bull": "^4.16.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "helmet": "^7.1.0", @@ -38,6 +40,11 @@ "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pg": "^8.12.0", + "polly-ts": "^0.1.0", + "polly-ts-core": "^0.1.1", + "polly-ts-nestjs": "^0.1.2", + "polly-ts-redis": "^0.1.2", + "polly-ts-telemetry": "^0.1.2", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "typeorm": "^0.3.20" diff --git a/src/app.controller.ts b/src/app.controller.ts index d0d1a1b..d9b4a5b 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -1,15 +1,21 @@ import { Controller, Get } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AppService } from './app.service'; +import { ResilienceService } from './modules/resilience/resilience.service'; @ApiTags('health') @Controller() export class AppController { - constructor(private readonly appService: AppService) {} + constructor( + private readonly appService: AppService, + private readonly resilienceService: ResilienceService, + ) {} @Get() - getRoot(): { name: string; status: string } { - return this.appService.getInfo(); + async getRoot(): Promise<{ name: string; status: string }> { + return this.resilienceService.resilientPipeline.execute(async () => + this.appService.getInfo(), + ); } @Get('health') diff --git a/src/app.module.ts b/src/app.module.ts index c584435..b79eca8 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -13,6 +13,9 @@ import { TransactionsModule } from './modules/transactions/transactions.module'; import { StellarModule } from './modules/stellar/stellar.module'; import { TradingEngineModule } from './modules/trading-engine/trading-engine.module'; import { WalletModule } from './modules/wallet/wallet.module'; +import { ErrorHandlerModule } from './modules/error-handler/error-handler.module'; +import { ResilienceModule } from './modules/resilience/resilience.module'; +import { QueueModule } from './modules/queue/queue.module'; @Module({ imports: [ @@ -29,8 +32,11 @@ import { WalletModule } from './modules/wallet/wallet.module'; StellarModule, TradingEngineModule, WalletModule, + ErrorHandlerModule, + ResilienceModule, + QueueModule, ], controllers: [AppController], providers: [AppService], }) -export class AppModule {} \ No newline at end of file +export class AppModule {} diff --git a/src/main.ts b/src/main.ts index 0e0c297..a9a33cb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,11 +2,9 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe, Logger } from '@nestjs/common'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import helmet from 'helmet'; -import { - AllExceptionsFilter, - TransformInterceptor, -} from '@app/common'; +import { AllExceptionsFilter, TransformInterceptor } from '@app/common'; import { AppModule } from './app.module'; +import { ErrorHandlerService } from './modules/error-handler/error-handler.service'; async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -29,7 +27,8 @@ async function bootstrap() { // Consistent success envelope and error shape across all endpoints. app.useGlobalInterceptors(new TransformInterceptor()); - app.useGlobalFilters(new AllExceptionsFilter()); + const errorHandler = app.get(ErrorHandlerService); + app.useGlobalFilters(new AllExceptionsFilter(errorHandler)); // CORS is enabled so the frontend can reach the API during development. app.enableCors(); diff --git a/src/modules/error-handler/error-handler.module.ts b/src/modules/error-handler/error-handler.module.ts new file mode 100644 index 0000000..0a95dd1 --- /dev/null +++ b/src/modules/error-handler/error-handler.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { ErrorHandlerService } from './error-handler.service'; + +@Module({ + providers: [ErrorHandlerService], + exports: [ErrorHandlerService], +}) +export class ErrorHandlerModule {} diff --git a/src/modules/error-handler/error-handler.service.spec.ts b/src/modules/error-handler/error-handler.service.spec.ts new file mode 100644 index 0000000..9745bd9 --- /dev/null +++ b/src/modules/error-handler/error-handler.service.spec.ts @@ -0,0 +1,51 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ErrorHandlerService } from './error-handler.service'; +import { DlqService } from '../queue/dlq.service'; +import { AppError, ErrorCategory, UnknownError } from './errors'; +import { HttpStatus } from '@nestjs/common'; + +class TestError extends AppError { + readonly category = ErrorCategory.TEST; + constructor() { + super('Test Error', HttpStatus.INTERNAL_SERVER_ERROR); + } +} + +describe('ErrorHandlerService', () => { + let service: ErrorHandlerService; + let dlqService: DlqService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ErrorHandlerService, + { + provide: DlqService, + useValue: { + add: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(ErrorHandlerService); + dlqService = module.get(DlqService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should handle AppError', () => { + const error = new TestError(); + const result = service.handle(error); + expect(result).toBe(error); + }); + + it('should handle unknown error', () => { + const error = new Error('Test Error'); + const result = service.handle(error); + expect(result).toBeInstanceOf(UnknownError); + expect(dlqService.add).toHaveBeenCalled(); + }); +}); diff --git a/src/modules/error-handler/error-handler.service.ts b/src/modules/error-handler/error-handler.service.ts new file mode 100644 index 0000000..b887501 --- /dev/null +++ b/src/modules/error-handler/error-handler.service.ts @@ -0,0 +1,37 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { AppError, UnknownError } from './errors'; +import { DlqService } from '../queue/dlq.service'; + +@Injectable() +export class ErrorHandlerService { + private readonly logger = new Logger(ErrorHandlerService.name); + + constructor(private readonly dlqService: DlqService) {} + + handle(error: unknown): AppError { + if (error instanceof AppError) { + this.logger.error( + `[${error.category}] ${error.message}`, + error.stack, + error.details, + ); + return error; + } + + const unknownError = new UnknownError('An unexpected error occurred', { + cause: error, + }); + this.logger.error( + `[${unknownError.category}] ${unknownError.message}`, + error instanceof Error ? error.stack : String(error), + ); + + // Add the error to the dead-letter queue + this.dlqService.add( + { originalError: error }, + error instanceof Error ? error : new Error(String(error)), + ); + + return unknownError; + } +} diff --git a/src/modules/error-handler/errors.ts b/src/modules/error-handler/errors.ts new file mode 100644 index 0000000..db75168 --- /dev/null +++ b/src/modules/error-handler/errors.ts @@ -0,0 +1,91 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +export enum ErrorCategory { + /** Errors originating from external APIs or services. */ + API = 'api', + /** Errors related to database operations. */ + DATABASE = 'database', + /** Errors due to invalid input data. */ + VALIDATION = 'validation', + /** Errors related to business logic. */ + BUSINESS = 'business', + /** Any other unexpected errors. */ + UNKNOWN = 'unknown', + /** Errors for testing purposes. */ + TEST = 'test', +} + +export interface AppErrorDetails { + /** The original error, preserved for logging (never serialized to clients). */ + cause?: unknown; + /** Any additional context to be logged. */ + [key: string]: any; +} + +export abstract class AppError extends HttpException { + abstract readonly category: ErrorCategory; + readonly details: AppErrorDetails; + + constructor( + message: string, + status: HttpStatus, + details: AppErrorDetails = {}, + ) { + super( + { + message, + error: message, + }, + status, + ); + this.details = details; + } + + toDetail() { + return { + category: this.category, + message: this.message, + ...this.details, + }; + } +} + +export class ApiError extends AppError { + readonly category = ErrorCategory.API; + + constructor(message: string, details: AppErrorDetails = {}) { + super(message, HttpStatus.SERVICE_UNAVAILABLE, details); + } +} + +export class DatabaseError extends AppError { + readonly category = ErrorCategory.DATABASE; + + constructor(message: string, details: AppErrorDetails = {}) { + super(message, HttpStatus.INTERNAL_SERVER_ERROR, details); + } +} + +export class ValidationError extends AppError { + readonly category = ErrorCategory.VALIDATION; + + constructor(message: string, details: AppErrorDetails = {}) { + super(message, HttpStatus.BAD_REQUEST, details); + } +} + +export class BusinessError extends AppError { + readonly category = ErrorCategory.BUSINESS; + + constructor(message: string, details: AppErrorDetails = {}) { + super(message, HttpStatus.UNPROCESSABLE_ENTITY, details); + } +} + +export class UnknownError extends AppError { + readonly category = ErrorCategory.UNKNOWN; + + constructor(message: string, details: AppErrorDetails = {}) { + super(message, HttpStatus.INTERNAL_SERVER_ERROR, details); + } +} diff --git a/src/modules/queue/dlq.service.ts b/src/modules/queue/dlq.service.ts new file mode 100644 index 0000000..f5b6e27 --- /dev/null +++ b/src/modules/queue/dlq.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bull'; +import { Queue } from 'bull'; + +@Injectable() +export class DlqService { + constructor(@InjectQueue('dead-letter-queue') private readonly dlq: Queue) {} + + async add(job: any, error: Error): Promise { + await this.dlq.add({ + job, + error: { + name: error.name, + message: error.message, + stack: error.stack, + }, + timestamp: new Date().toISOString(), + }); + } +} diff --git a/src/modules/queue/queue.module.ts b/src/modules/queue/queue.module.ts new file mode 100644 index 0000000..6dbe5b8 --- /dev/null +++ b/src/modules/queue/queue.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bull'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { DlqService } from './dlq.service'; + +@Module({ + imports: [ + BullModule.forRootAsync({ + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + redis: { + host: configService.get('REDIS_HOST'), + port: configService.get('REDIS_PORT'), + }, + }), + inject: [ConfigService], + }), + BullModule.registerQueue({ + name: 'dead-letter-queue', + }), + ], + providers: [DlqService], + exports: [BullModule, DlqService], +}) +export class QueueModule {} diff --git a/src/modules/resilience/resilience.module.ts b/src/modules/resilience/resilience.module.ts new file mode 100644 index 0000000..c7790cb --- /dev/null +++ b/src/modules/resilience/resilience.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { ResilienceService } from './resilience.service'; +import { PollyModule } from 'polly-ts-nestjs'; + +@Module({ + imports: [ + PollyModule.register({ + policies: [], + }), + ], + providers: [ResilienceService], + exports: [ResilienceService], +}) +export class ResilienceModule {} diff --git a/src/modules/resilience/resilience.service.spec.ts b/src/modules/resilience/resilience.service.spec.ts new file mode 100644 index 0000000..261f266 --- /dev/null +++ b/src/modules/resilience/resilience.service.spec.ts @@ -0,0 +1,71 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ResilienceService } from './resilience.service'; +import { ApiError } from '../error-handler/errors'; +import { RedisModule } from '../../redis/redis.module'; +import { ConfigModule } from '@nestjs/config'; +import { Redis } from 'ioredis'; +import { CircuitOpenError } from 'polly-ts-core'; + +describe('ResilienceService', () => { + let service: ResilienceService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + RedisModule, + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: '.env.test', + }), + ], + providers: [ + ResilienceService, + { + provide: 'REDIS_CLIENT', + useValue: new Redis(), + }, + ], + }).compile(); + + service = module.get(ResilienceService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should retry on ApiError', async () => { + const mockFn = jest.fn(); + mockFn.mockRejectedValueOnce(new ApiError('API Error')); + mockFn.mockResolvedValueOnce('Success'); + + const result = await service.execute(mockFn); + + expect(result).toBe('Success'); + expect(mockFn).toHaveBeenCalledTimes(2); + }); + + it('should open circuit breaker after failures', async () => { + const mockFn = jest.fn(); + mockFn.mockRejectedValue(new ApiError('API Error')); + + for (let i = 0; i < 5; i++) { + await expect(service.execute(mockFn)).rejects.toThrow(ApiError); + } + + // Circuit should be open now + await expect(service.execute(mockFn)).rejects.toThrow(CircuitOpenError); + }); + + it('should fallback after failures', async () => { + const mockFn = jest.fn(); + mockFn.mockRejectedValue(new Error('Some other error')); + + const result = await service.execute(mockFn); + + expect(result).toEqual({ + name: 'InterChangableTrade Core API', + status: 'degraded', + }); + }); +}); diff --git a/src/modules/resilience/resilience.service.ts b/src/modules/resilience/resilience.service.ts new file mode 100644 index 0000000..e0a256b --- /dev/null +++ b/src/modules/resilience/resilience.service.ts @@ -0,0 +1,61 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { + IPolicy, + RetryPolicy, + CircuitBreakerPolicy, + FallbackPolicy, + pipeline, + ExponentialBackoffWithJitter, + MemoryStateStore, + RetryEventArgs, +} from 'polly-ts-core'; +import { Redis } from 'ioredis'; +import { ApiError } from '../error-handler/errors'; + +@Injectable() +export class ResilienceService { + public readonly resilientPipeline: IPolicy; + private readonly logger = new Logger(ResilienceService.name); + + constructor(@Inject('REDIS_CLIENT') private readonly redis: Redis) { + const retryPolicy = new RetryPolicy({ + maxAttempts: 3, + backoff: new ExponentialBackoffWithJitter({ initialDelay: 100 }), + shouldRetryError: (err: Error) => err instanceof ApiError, + }); + retryPolicy.onRetry(({ error, attemptNumber }: RetryEventArgs) => { + if (error) { + this.logger.warn( + `Retrying operation, attempt ${attemptNumber}. Error: ${error.message}`, + ); + } + }); + + const circuitBreakerPolicy = new CircuitBreakerPolicy({ + stateStore: new MemoryStateStore(5, 30000, 3), + shouldHandle: (err: Error) => err instanceof ApiError, + }); + + const fallbackPolicy: IPolicy = new FallbackPolicy({ + fallback: (err: Error) => { + this.logger.warn( + `Fallback triggered due to: ${err.message}. Service is temporarily unavailable.`, + ); + return { + name: 'InterChangableTrade Core API', + status: 'degraded', + }; + }, + }); + + this.resilientPipeline = pipeline( + fallbackPolicy, + retryPolicy, + circuitBreakerPolicy, + ); + } + + async execute(fn: () => Promise): Promise { + return this.resilientPipeline.execute(() => fn()); + } +}