diff --git a/lib/health-check/health-check-executor.service.spec.ts b/lib/health-check/health-check-executor.service.spec.ts index 328b090a9..0bd1c2ae0 100644 --- a/lib/health-check/health-check-executor.service.spec.ts +++ b/lib/health-check/health-check-executor.service.spec.ts @@ -28,6 +28,14 @@ const unhealthyCheckSync = () => { }); }; +const unhealthyCheckWithoutError = async (): Promise => { + return { + unhealthy: { + status: 'down', + }, + }; +}; + describe('HealthCheckExecutorService', () => { let healthCheckExecutor: HealthCheckExecutor; @@ -125,5 +133,25 @@ describe('HealthCheckExecutorService', () => { }, }); }); + + it('should return a result object with errors when error is not an instance of HealthCheckError', async () => { + const result = await healthCheckExecutor.execute([ + () => unhealthyCheckWithoutError(), + ]); + expect(result).toEqual({ + status: 'error', + info: {}, + error: { + unhealthy: { + status: 'down', + }, + }, + details: { + unhealthy: { + status: 'down', + }, + }, + }); + }); }); }); diff --git a/lib/health-check/health-check-executor.service.ts b/lib/health-check/health-check-executor.service.ts index 258d29362..4a25fab4b 100644 --- a/lib/health-check/health-check-executor.service.ts +++ b/lib/health-check/health-check-executor.service.ts @@ -65,14 +65,20 @@ export class HealthCheckExecutor implements BeforeApplicationShutdown { result.forEach((res) => { if (res.status === 'fulfilled') { - results.push(res.value); + Object.entries(res.value).forEach(([key, value]) => { + if (value.status === 'up') { + results.push({ [key]: value }); + } else if (value.status === 'down') { + errors.push({ [key]: value }); + } + }); } else { const error = res.reason; // Is not an expected error. Throw further! if (!isHealthCheckError(error)) { throw error; } - // Is a expected health check error + errors.push((error as HealthCheckError).causes); } }); diff --git a/lib/health-indicator/database/mikro-orm.health.ts b/lib/health-indicator/database/mikro-orm.health.ts index f413659ff..c7d1cd9bf 100644 --- a/lib/health-indicator/database/mikro-orm.health.ts +++ b/lib/health-indicator/database/mikro-orm.health.ts @@ -2,14 +2,13 @@ import type * as MikroOrm from '@mikro-orm/core'; import { Injectable, Scope } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { HealthIndicator, type HealthIndicatorResult } from '..'; -import { TimeoutError } from '../../errors'; import { DatabaseNotConnectedError } from '../../errors/database-not-connected.error'; -import { HealthCheckError } from '../../health-check/health-check.error'; import { TimeoutError as PromiseTimeoutError, promiseTimeout, checkPackages, } from '../../utils'; +import { HealthIndicatorService } from '../health-indicator.service'; export interface MikroOrmPingCheckSettings { /** @@ -31,68 +30,14 @@ export interface MikroOrmPingCheckSettings { */ @Injectable({ scope: Scope.TRANSIENT }) export class MikroOrmHealthIndicator extends HealthIndicator { - /** - * Initializes the MikroOrmHealthIndicator - * - * @param {ModuleRef} moduleRef The NestJS module reference - */ - constructor(private moduleRef: ModuleRef) { + constructor( + private readonly moduleRef: ModuleRef, + private readonly healthIndicatorService: HealthIndicatorService, + ) { super(); this.checkDependantPackages(); } - /** - * Checks if responds in (default) 1000ms and - * returns a result object corresponding to the result - * @param key The key which will be used for the result object - * @param options The options for the ping - * - * @example - * MikroOrmHealthIndicator.pingCheck('database', { timeout: 1500 }); - */ - public async pingCheck( - key: string, - options: MikroOrmPingCheckSettings = {}, - ): Promise { - this.checkDependantPackages(); - - const connection = options.connection || this.getContextConnection(); - const timeout = options.timeout || 1000; - - if (!connection) { - return this.getStatus(key, false); - } - - try { - await this.pingDb(connection, timeout); - } catch (error) { - // Check if the error is a timeout error - if (error instanceof PromiseTimeoutError) { - throw new TimeoutError( - timeout, - this.getStatus(key, false, { - message: `timeout of ${timeout}ms exceeded`, - }), - ); - } - if (error instanceof DatabaseNotConnectedError) { - throw new HealthCheckError( - error.message, - this.getStatus(key, false, { - message: error.message, - }), - ); - } - - throw new HealthCheckError( - `${key} is not available`, - this.getStatus(key, false), - ); - } - - return this.getStatus(key, true); - } - private checkDependantPackages() { checkPackages( ['@mikro-orm/nestjs', '@mikro-orm/core'], @@ -133,4 +78,44 @@ export class MikroOrmHealthIndicator extends HealthIndicator { return await promiseTimeout(timeout, checker()); } + + /** + * Checks if responds in (default) 1000ms and + * returns a result object corresponding to the result + * @param key The key which will be used for the result object + * @param options The options for the ping + * + * @example + * MikroOrmHealthIndicator.pingCheck('database', { timeout: 1500 }); + */ + public async pingCheck( + key: Key, + options: MikroOrmPingCheckSettings = {}, + ): Promise> { + this.checkDependantPackages(); + const check = this.healthIndicatorService.check(key); + + const timeout = options.timeout || 1000; + const connection = options.connection || this.getContextConnection(); + + if (!connection) { + return check.down(); + } + + try { + await this.pingDb(connection, timeout); + } catch (error) { + // Check if the error is a timeout error + if (error instanceof PromiseTimeoutError) { + return check.down(`timeout of ${timeout}ms exceeded`); + } + if (error instanceof DatabaseNotConnectedError) { + return check.down(error.message); + } + + return check.down(); + } + + return check.up(); + } } diff --git a/lib/health-indicator/database/mongoose.health.ts b/lib/health-indicator/database/mongoose.health.ts index f87cfc09d..71b253722 100644 --- a/lib/health-indicator/database/mongoose.health.ts +++ b/lib/health-indicator/database/mongoose.health.ts @@ -1,18 +1,14 @@ import { Injectable, Scope } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import type * as NestJSMongoose from '@nestjs/mongoose'; -import { - type HealthIndicatorResult, - TimeoutError, - ConnectionNotFoundError, -} from '../..'; -import { HealthCheckError } from '../../health-check/health-check.error'; +import { type HealthIndicatorResult } from '../..'; import { promiseTimeout, TimeoutError as PromiseTimeoutError, checkPackages, } from '../../utils'; import { HealthIndicator } from '../health-indicator'; +import { HealthIndicatorService } from '../health-indicator.service'; export interface MongoosePingCheckSettings { /** @@ -34,12 +30,10 @@ export interface MongoosePingCheckSettings { */ @Injectable({ scope: Scope.TRANSIENT }) export class MongooseHealthIndicator extends HealthIndicator { - /** - * Initializes the MongooseHealthIndicator - * - * @param {ModuleRef} moduleRef The NestJS module reference - */ - constructor(private moduleRef: ModuleRef) { + constructor( + private readonly moduleRef: ModuleRef, + private readonly healthIndicatorService: HealthIndicatorService, + ) { super(); this.checkDependantPackages(); } @@ -92,45 +86,30 @@ export class MongooseHealthIndicator extends HealthIndicator { * @example * mongooseHealthIndicator.pingCheck('mongodb', { timeout: 1500 }); */ - public async pingCheck( - key: string, + public async pingCheck( + key: Key, options: MongoosePingCheckSettings = {}, - ): Promise { - let isHealthy = false; + ): Promise> { this.checkDependantPackages(); + const check = this.healthIndicatorService.check(key); const connection = options.connection || this.getContextConnection(); const timeout = options.timeout || 1000; if (!connection) { - throw new ConnectionNotFoundError( - this.getStatus(key, isHealthy, { - message: 'Connection provider not found in application context', - }), - ); + return check.down('Connection provider not found in application context'); } try { await this.pingDb(connection, timeout); - isHealthy = true; } catch (err) { if (err instanceof PromiseTimeoutError) { - throw new TimeoutError( - timeout, - this.getStatus(key, isHealthy, { - message: `timeout of ${timeout}ms exceeded`, - }), - ); + return check.down(`timeout of ${timeout}ms exceeded`); } - } - if (isHealthy) { - return this.getStatus(key, isHealthy); - } else { - throw new HealthCheckError( - `${key} is not available`, - this.getStatus(key, isHealthy), - ); + return check.down(); } + + return check.up(); } } diff --git a/lib/health-indicator/database/prisma.health.ts b/lib/health-indicator/database/prisma.health.ts index ccaed51e7..ffe93893d 100644 --- a/lib/health-indicator/database/prisma.health.ts +++ b/lib/health-indicator/database/prisma.health.ts @@ -1,11 +1,11 @@ import { Injectable } from '@nestjs/common'; -import { TimeoutError } from '../../errors'; -import { HealthCheckError } from '../../health-check'; import { promiseTimeout, TimeoutError as PromiseTimeoutError, } from '../../utils'; import { HealthIndicator } from '../health-indicator'; +import { type HealthIndicatorResult } from '../health-indicator-result.interface'; +import { HealthIndicatorService } from '../health-indicator.service'; type PingCommandSignature = { [Key in string]?: number }; @@ -35,7 +35,7 @@ export interface PrismaClientPingCheckSettings { */ @Injectable() export class PrismaHealthIndicator extends HealthIndicator { - constructor() { + constructor(private readonly healthIndicatorService: HealthIndicatorService) { super(); } @@ -69,35 +69,24 @@ export class PrismaHealthIndicator extends HealthIndicator { * @param prismaClient PrismaClient * @param options The options for the ping */ - public async pingCheck( - key: string, + public async pingCheck( + key: Key, prismaClient: PrismaClient, options: PrismaClientPingCheckSettings = {}, - ): Promise { - let isHealthy = false; + ): Promise> { + const check = this.healthIndicatorService.check(key); const timeout = options.timeout || 1000; try { await this.pingDb(timeout, prismaClient); - isHealthy = true; } catch (error) { if (error instanceof PromiseTimeoutError) { - throw new TimeoutError( - timeout, - this.getStatus(key, isHealthy, { - message: `timeout of ${timeout}ms exceeded`, - }), - ); + return check.down(`timeout of ${timeout}ms exceeded`); } - } - if (isHealthy) { - return this.getStatus(key, isHealthy); - } else { - throw new HealthCheckError( - `${key} is not available`, - this.getStatus(key, isHealthy), - ); + return check.down(); } + + return check.up(); } } diff --git a/lib/health-indicator/database/sequelize.health.ts b/lib/health-indicator/database/sequelize.health.ts index c9b5cb8c7..882890937 100644 --- a/lib/health-indicator/database/sequelize.health.ts +++ b/lib/health-indicator/database/sequelize.health.ts @@ -1,18 +1,14 @@ import { Injectable, Scope } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import type * as NestJSSequelize from '@nestjs/sequelize'; -import { - type HealthIndicatorResult, - TimeoutError, - ConnectionNotFoundError, -} from '../..'; -import { HealthCheckError } from '../../health-check/health-check.error'; +import { type HealthIndicatorResult } from '../..'; import { promiseTimeout, TimeoutError as PromiseTimeoutError, checkPackages, } from '../../utils'; import { HealthIndicator } from '../health-indicator'; +import { HealthIndicatorService } from '../health-indicator.service'; export interface SequelizePingCheckSettings { /** @@ -34,12 +30,10 @@ export interface SequelizePingCheckSettings { */ @Injectable({ scope: Scope.TRANSIENT }) export class SequelizeHealthIndicator extends HealthIndicator { - /** - * Initializes the SequelizeHealthIndicator - * - * @param {ModuleRef} moduleRef The NestJS module reference - */ - constructor(private moduleRef: ModuleRef) { + constructor( + private readonly moduleRef: ModuleRef, + private readonly healthIndicatorService: HealthIndicatorService, + ) { super(); this.checkDependantPackages(); } @@ -88,45 +82,30 @@ export class SequelizeHealthIndicator extends HealthIndicator { * @example * sequelizeHealthIndicator.pingCheck('database', { timeout: 1500 }); */ - public async pingCheck( - key: string, + public async pingCheck( + key: Key, options: SequelizePingCheckSettings = {}, - ): Promise { - let isHealthy = false; + ): Promise> { this.checkDependantPackages(); + const check = this.healthIndicatorService.check(key); const connection = options.connection || this.getContextConnection(); const timeout = options.timeout || 1000; if (!connection) { - throw new ConnectionNotFoundError( - this.getStatus(key, isHealthy, { - message: 'Connection provider not found in application context', - }), - ); + return check.down('Connection provider not found in application context'); } try { await this.pingDb(connection, timeout); - isHealthy = true; } catch (err) { if (err instanceof PromiseTimeoutError) { - throw new TimeoutError( - timeout, - this.getStatus(key, isHealthy, { - message: `timeout of ${timeout}ms exceeded`, - }), - ); + return check.down(`timeout of ${timeout}ms exceeded`); } - } - if (isHealthy) { - return this.getStatus(key, isHealthy); - } else { - throw new HealthCheckError( - `${key} is not available`, - this.getStatus(key, isHealthy), - ); + return check.down(); } + + return check.up(); } } diff --git a/lib/health-indicator/database/typeorm.health.ts b/lib/health-indicator/database/typeorm.health.ts index c8f5eaa4d..fac48e35e 100644 --- a/lib/health-indicator/database/typeorm.health.ts +++ b/lib/health-indicator/database/typeorm.health.ts @@ -3,17 +3,13 @@ import { ModuleRef } from '@nestjs/core'; import type * as NestJSTypeOrm from '@nestjs/typeorm'; import type * as TypeOrm from 'typeorm'; import { HealthIndicator, type HealthIndicatorResult } from '../'; -import { - TimeoutError, - ConnectionNotFoundError, - MongoConnectionError, -} from '../../errors'; -import { HealthCheckError } from '../../health-check/health-check.error'; +import { MongoConnectionError } from '../../errors'; import { TimeoutError as PromiseTimeoutError, promiseTimeout, checkPackages, } from '../../utils'; +import { HealthIndicatorService } from '../health-indicator.service'; export interface TypeOrmPingCheckSettings { /** @@ -36,12 +32,10 @@ export interface TypeOrmPingCheckSettings { */ @Injectable({ scope: Scope.TRANSIENT }) export class TypeOrmHealthIndicator extends HealthIndicator { - /** - * Initializes the TypeOrmHealthIndicator - * - * @param {ModuleRef} moduleRef The NestJS module reference - */ - constructor(private moduleRef: ModuleRef) { + constructor( + private readonly moduleRef: ModuleRef, + private readonly healthIndicatorService: HealthIndicatorService, + ) { super(); this.checkDependantPackages(); } @@ -127,11 +121,11 @@ export class TypeOrmHealthIndicator extends HealthIndicator { * @example * typeOrmHealthIndicator.pingCheck('database', { timeout: 1500 }); */ - async pingCheck( - key: string, + async pingCheck( + key: Key, options: TypeOrmPingCheckSettings = {}, - ): Promise { - let isHealthy = false; + ): Promise> { + const check = this.healthIndicatorService.check(key); this.checkDependantPackages(); const connection: TypeOrm.DataSource | null = @@ -139,42 +133,22 @@ export class TypeOrmHealthIndicator extends HealthIndicator { const timeout = options.timeout || 1000; if (!connection) { - throw new ConnectionNotFoundError( - this.getStatus(key, isHealthy, { - message: 'Connection provider not found in application context', - }), - ); + return check.down('Connection provider not found in application context'); } try { await this.pingDb(connection, timeout); - isHealthy = true; } catch (err) { if (err instanceof PromiseTimeoutError) { - throw new TimeoutError( - timeout, - this.getStatus(key, isHealthy, { - message: `timeout of ${timeout}ms exceeded`, - }), - ); + return check.down(`timeout of ${timeout}ms exceeded`); } if (err instanceof MongoConnectionError) { - throw new HealthCheckError( - err.message, - this.getStatus(key, isHealthy, { - message: err.message, - }), - ); + return check.down(err.message); } - } - if (isHealthy) { - return this.getStatus(key, isHealthy); - } else { - throw new HealthCheckError( - `${key} is not available`, - this.getStatus(key, isHealthy), - ); + return check.down(); } + + return check.up(); } } diff --git a/lib/health-indicator/disk/disk.health.ts b/lib/health-indicator/disk/disk.health.ts index b08d0d19a..ef2b8ecdd 100644 --- a/lib/health-indicator/disk/disk.health.ts +++ b/lib/health-indicator/disk/disk.health.ts @@ -6,9 +6,9 @@ import { type DiskOptionsWithThresholdPercent, } from './disk-health-options.type'; import { HealthIndicator, type HealthIndicatorResult } from '../'; -import { StorageExceededError } from '../../errors'; import { STORAGE_EXCEEDED } from '../../errors/messages.constant'; import { CHECK_DISK_SPACE_LIB } from '../../terminus.constants'; +import { HealthIndicatorService } from '../health-indicator.service'; type CheckDiskSpace = typeof checkDiskSpace; @@ -21,16 +21,10 @@ type CheckDiskSpace = typeof checkDiskSpace; */ @Injectable() export class DiskHealthIndicator extends HealthIndicator { - /** - * Initializes the health indicator - * - * @param {CheckDiskSpace} checkDiskSpace The check-disk-space library - * - * @internal - */ constructor( @Inject(CHECK_DISK_SPACE_LIB) - private checkDiskSpace: CheckDiskSpace, + private readonly checkDiskSpace: CheckDiskSpace, + private readonly healthIndicatorService: HealthIndicatorService, ) { super(); } @@ -70,13 +64,19 @@ export class DiskHealthIndicator extends HealthIndicator { * // The used disk storage should not exceed 50% of the full disk size * diskHealthIndicator.checkStorage('storage', { thresholdPercent: 0.5, path: 'C:\\' }); */ - public async checkStorage( - key: string, + public async checkStorage( + key: Key, options: DiskHealthIndicatorOptions, - ): Promise { + ): Promise> { + const check = this.healthIndicatorService.check(key); const { free, size } = await this.checkDiskSpace(options.path); const used = size - free; + // Prevent division by zero + if (isNaN(size) || size === 0) { + return check.down(STORAGE_EXCEEDED('disk storage')); + } + let isHealthy = false; if (this.isOptionThresholdPercent(options)) { isHealthy = options.thresholdPercent >= used / size; @@ -85,13 +85,8 @@ export class DiskHealthIndicator extends HealthIndicator { } if (!isHealthy) { - throw new StorageExceededError( - 'disk storage', - this.getStatus(key, false, { - message: STORAGE_EXCEEDED('disk storage'), - }), - ); + return check.down(STORAGE_EXCEEDED('disk storage')); } - return this.getStatus(key, true); + return check.up(); } } diff --git a/lib/health-indicator/health-indicator-result.interface.ts b/lib/health-indicator/health-indicator-result.interface.ts index 0b9f4a5ef..7eccc3fb6 100644 --- a/lib/health-indicator/health-indicator-result.interface.ts +++ b/lib/health-indicator/health-indicator-result.interface.ts @@ -7,18 +7,8 @@ export type HealthIndicatorStatus = 'up' | 'down'; * The result object of a health indicator * @publicApi */ -export type HealthIndicatorResult = { - /** - * The key of the health indicator which should be unique - */ - [key: string]: { - /** - * The status if the given health indicator was successful or not - */ - status: HealthIndicatorStatus; - /** - * Optional settings of the health indicator result - */ - [optionalKeys: string]: any; - }; -}; +export type HealthIndicatorResult< + Key extends string = string, + Status extends HealthIndicatorStatus = HealthIndicatorStatus, + OptionalData extends Record = Record, +> = Record; diff --git a/lib/health-indicator/health-indicator.service.ts b/lib/health-indicator/health-indicator.service.ts new file mode 100644 index 000000000..9c9f1b447 --- /dev/null +++ b/lib/health-indicator/health-indicator.service.ts @@ -0,0 +1,79 @@ +import { Injectable } from '@nestjs/common'; +import { type HealthIndicatorResult } from './health-indicator-result.interface'; + +/** + * ONLY USED INTERNALLY + * The goal is to make this service public in the future + * This is a helper service which can be used to create health indicator results + * @internal + */ +@Injectable() +export class HealthIndicatorService { + check(key: Key) { + return new HealthIndicatorSession(key); + } +} + +type AdditionalData = Record; + +export class HealthIndicatorSession = string> { + constructor(private readonly key: Key) {} + + /** + * Mark the health indicator as down + * @param data additional data which will get appended to the result object + */ + down( + data?: T, + ): HealthIndicatorResult; + down( + data?: T, + ): HealthIndicatorResult; + down( + data?: T, + ): HealthIndicatorResult { + let additionalData: AdditionalData = {}; + + if (typeof data === 'string') { + additionalData = { message: data }; + } else if (typeof data === 'object') { + additionalData = data; + } + + const detail = { + status: 'down' as const, + ...additionalData, + }; + + return { + [this.key]: detail, + // TypeScript does not infer this.key as Key correctly. + } as Record; + } + + up(data?: T): HealthIndicatorResult; + up( + data?: T, + ): HealthIndicatorResult; + up( + data?: T, + ): HealthIndicatorResult { + let additionalData: AdditionalData = {}; + + if (typeof data === 'string') { + additionalData = { message: data }; + } else if (typeof data === 'object') { + additionalData = data; + } + + const detail = { + status: 'up' as const, + ...additionalData, + }; + + return { + [this.key]: detail, + // TypeScript does not infer this.key as Key correctly. + } as Record; + } +} diff --git a/lib/health-indicator/http/http.health.spec.ts b/lib/health-indicator/http/http.health.spec.ts index c906ae2ce..27365d83a 100644 --- a/lib/health-indicator/http/http.health.spec.ts +++ b/lib/health-indicator/http/http.health.spec.ts @@ -7,6 +7,8 @@ import { TERMINUS_LOGGER } from '../../health-check/logger/logger.provider'; import { AxiosError } from 'axios'; import { AxiosRequestConfig } from './axios.interfaces'; import { HealthCheckError } from 'lib/health-check'; +import { HealthIndicator } from '../health-indicator'; +import { HealthIndicatorService } from '../health-indicator.service'; jest.mock('../../utils/checkPackage.util'); // == MOCKS == @@ -33,6 +35,7 @@ describe('Http Response Health Indicator', () => { imports: [HttpModule], providers: [ HttpHealthIndicator, + HealthIndicatorService, { provide: nestJSAxiosMock.HttpService as any, useValue: httpServiceMock, diff --git a/lib/health-indicator/http/http.health.ts b/lib/health-indicator/http/http.health.ts index f909ba344..45aae0d96 100644 --- a/lib/health-indicator/http/http.health.ts +++ b/lib/health-indicator/http/http.health.ts @@ -9,9 +9,12 @@ import { } from './axios.interfaces'; import { HealthIndicator, type HealthIndicatorResult } from '..'; import { type AxiosError } from '../../errors/axios.error'; -import { HealthCheckError } from '../../health-check/health-check.error'; import { TERMINUS_LOGGER } from '../../health-check/logger/logger.provider'; import { checkPackages, isAxiosError } from '../../utils'; +import { + HealthIndicatorService, + type HealthIndicatorSession, +} from '../health-indicator.service'; interface HttpClientLike { request(config: any): Observable>; @@ -34,6 +37,7 @@ export class HttpHealthIndicator extends HealthIndicator { private readonly moduleRef: ModuleRef, @Inject(TERMINUS_LOGGER) private readonly logger: ConsoleLogger, + private readonly healthIndicatorService: HealthIndicatorService, ) { super(); if (this.logger instanceof ConsoleLogger) { @@ -74,11 +78,10 @@ export class HttpHealthIndicator extends HealthIndicator { * * @throws {HealthCheckError} */ - private generateHttpError(key: string, error: AxiosError | any) { - if (!isAxiosError(error)) { - return; - } - + private generateHttpError( + check: HealthIndicatorSession, + error: AxiosError | any, + ) { const response: { [key: string]: any } = { message: error.message, }; @@ -88,10 +91,7 @@ export class HttpHealthIndicator extends HealthIndicator { response.statusText = error.response.statusText; } - throw new HealthCheckError( - error.message, - this.getStatus(key, false, response), - ); + return check.down(response); } /** @@ -106,15 +106,16 @@ export class HttpHealthIndicator extends HealthIndicator { * @example * httpHealthIndicator.pingCheck('google', 'https://google.com', { timeout: 800 }) */ - async pingCheck( - key: string, + async pingCheck( + key: Key, url: string, { httpClient, ...options }: AxiosRequestConfig & { httpClient?: HttpClientLike } = {}, - ): Promise { - let isHealthy = false; + ): Promise> { + const check = this.healthIndicatorService.check(key); + // In case the user has a preconfigured HttpService (see `HttpModule.register`) // we just let him/her pass in this HttpService so that he/she does not need to // reconfigure it. @@ -123,23 +124,27 @@ export class HttpHealthIndicator extends HealthIndicator { try { await lastValueFrom(httpService.request({ url, ...options })); - isHealthy = true; } catch (err) { - this.generateHttpError(key, err); + if (isAxiosError(err)) { + return this.generateHttpError(check, err); + } + + throw err; } - return this.getStatus(key, isHealthy); + return check.up(); } - async responseCheck( - key: string, + async responseCheck( + key: Key, url: URL | string, callback: (response: AxiosResponse) => boolean | Promise, { httpClient, ...options }: AxiosRequestConfig & { httpClient?: HttpClientLike } = {}, - ): Promise { + ): Promise> { + const check = this.healthIndicatorService.check(key); const httpService = httpClient || this.getHttpService(); let response: AxiosResponse; @@ -153,10 +158,14 @@ export class HttpHealthIndicator extends HealthIndicator { if (!isAxiosError(error)) { throw error; } + // We received an Axios Error but no response for unknown reasons. if (!error.response) { - throw this.generateHttpError(key, error); + return check.down(error.message); } + // We store the response no matter if the http request was successful or not. + // So that we can pass it to the callback function and the user can decide + // if the response is healthy or not. response = error.response; axiosError = error; } @@ -165,15 +174,12 @@ export class HttpHealthIndicator extends HealthIndicator { if (!isHealthy) { if (axiosError) { - throw this.generateHttpError(key, axiosError); + return this.generateHttpError(check, axiosError); } - throw new HealthCheckError( - `${key} is not available`, - this.getStatus(key, false), - ); + return check.down(); } - return this.getStatus(key, true); + return check.up(); } } diff --git a/lib/health-indicator/memory/memory.health.ts b/lib/health-indicator/memory/memory.health.ts index f9b434af1..a58482e36 100644 --- a/lib/health-indicator/memory/memory.health.ts +++ b/lib/health-indicator/memory/memory.health.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { HealthIndicator, type HealthIndicatorResult } from '../'; -import { StorageExceededError } from '../../errors'; import { STORAGE_EXCEEDED } from '../../errors/messages.constant'; +import { HealthIndicatorService } from '../health-indicator.service'; /** * The MemoryHealthIndicator contains checks which are related @@ -12,6 +12,10 @@ import { STORAGE_EXCEEDED } from '../../errors/messages.constant'; */ @Injectable() export class MemoryHealthIndicator extends HealthIndicator { + constructor(private readonly healthIndicatorService: HealthIndicatorService) { + super(); + } + /** * Checks the heap space and returns the status * @@ -27,22 +31,18 @@ export class MemoryHealthIndicator extends HealthIndicator { * // The process should not use more than 150MB memory * memoryHealthIndicator.checkHeap('memory_heap', 150 * 1024 * 1024); */ - public async checkHeap( - key: string, + public async checkHeap( + key: Key, heapUsedThreshold: number, - ): Promise { + ): Promise> { + const check = this.healthIndicatorService.check(key); const { heapUsed } = process.memoryUsage(); if (heapUsedThreshold < heapUsed) { - throw new StorageExceededError( - 'heap', - this.getStatus(key, false, { - message: STORAGE_EXCEEDED('heap'), - }), - ); + return check.down(STORAGE_EXCEEDED('heap')); } - return this.getStatus(key, true); + return check.up(); } /** @@ -59,21 +59,17 @@ export class MemoryHealthIndicator extends HealthIndicator { * // The process should not have more than 150MB allocated * memoryHealthIndicator.checkRSS('memory_rss', 150 * 1024 * 1024); */ - public async checkRSS( - key: string, + public async checkRSS( + key: Key, rssThreshold: number, - ): Promise { + ): Promise> { + const check = this.healthIndicatorService.check(key); const { rss } = process.memoryUsage(); if (rssThreshold < rss) { - throw new StorageExceededError( - 'rss', - this.getStatus(key, false, { - message: STORAGE_EXCEEDED('rss'), - }), - ); + return check.down(STORAGE_EXCEEDED('rss')); } - return this.getStatus(key, true); + return check.up(); } } diff --git a/lib/health-indicator/microservice/grpc.health.spec.ts b/lib/health-indicator/microservice/grpc.health.spec.ts index b83bc3356..fa9ed9feb 100644 --- a/lib/health-indicator/microservice/grpc.health.spec.ts +++ b/lib/health-indicator/microservice/grpc.health.spec.ts @@ -3,6 +3,9 @@ import { checkPackages } from '../../utils/checkPackage.util'; import { GrpcOptions, Transport } from '@nestjs/microservices'; import { UnhealthyResponseCodeError, TimeoutError } from '../../errors'; import { HealthCheckError } from '../../health-check/health-check.error'; +import { Test } from '@nestjs/testing'; +import { HealthIndicatorService } from '../health-indicator.service'; + jest.mock('../../utils/checkPackage.util'); // == MOCKS == @@ -24,14 +27,17 @@ const nestJSMicroservicesMock = { ClientProxyFactory: clientProxyFactoryMock, }; -let grpc: GRPCHealthIndicator; - describe('GRPCHealthIndicator', () => { + let grpc: GRPCHealthIndicator; beforeEach(async () => { (checkPackages as jest.Mock).mockImplementation((): any => [ nestJSMicroservicesMock, ]); - grpc = new GRPCHealthIndicator(); + + const moduleRef = await Test.createTestingModule({ + providers: [GRPCHealthIndicator, HealthIndicatorService], + }).compile(); + grpc = await moduleRef.resolve(GRPCHealthIndicator); }); afterEach(async () => { @@ -39,6 +45,7 @@ describe('GRPCHealthIndicator', () => { grpcClientMock.getService.mockClear(); healthServiceMock.check.mockClear(); }); + describe('checkService', () => { it('should return a healthy result', async () => { const result = await grpc.checkService('grpc', 'test'); @@ -54,6 +61,7 @@ describe('GRPCHealthIndicator', () => { transport: Transport.GRPC, }); }); + it('should correctly all the ClientProxyFactory with custom options', async () => { await grpc.checkService('grpc', 'test', { protoPath: 'test.proto', @@ -65,6 +73,7 @@ describe('GRPCHealthIndicator', () => { transport: Transport.GRPC, }); }); + it('should throw an error in case the health service returns a faulty response code', async () => { healthServiceMock.check.mockImplementationOnce((): any => ({ toPromise: (): any => Promise.resolve({ status: 0 }), @@ -75,6 +84,7 @@ describe('GRPCHealthIndicator', () => { expect(err instanceof UnhealthyResponseCodeError).toBeTruthy(); } }); + it('should throw an error when the timeout runs out', async () => { try { await grpc.checkService('grpc', 'test', { timeout: 0 }); @@ -82,6 +92,7 @@ describe('GRPCHealthIndicator', () => { expect(err instanceof TimeoutError).toBeTruthy(); } }); + it('should use the custom healthServiceCheck function', async () => { const healthServiceCheck = jest .fn() @@ -93,12 +104,14 @@ describe('GRPCHealthIndicator', () => { expect(healthServiceCheck.mock.calls.length).toBe(1); }); + it('should use the custom healthServiceName', async () => { await grpc.checkService('grpc', 'test', { healthServiceName: 'health2', }); expect(grpcClientMock.getService.mock.calls[0][0]).toBe('health2'); }); + it('should throw TypeError further in client.getService', async () => { const error = new TypeError('test'); grpcClientMock.getService.mockImplementationOnce((): any => { @@ -110,17 +123,20 @@ describe('GRPCHealthIndicator', () => { expect(err).toEqual(error); } }); + it('should throw HealthCheckError in client.getService', async () => { const error = new Error('test'); grpcClientMock.getService.mockImplementationOnce((): any => { throw error; }); + try { await grpc.checkService('grpc', 'test'); } catch (err) { expect(err instanceof HealthCheckError).toBeTruthy(); } }); + it('should throw HealthCheckError if the grpc check function fails', async () => { try { await grpc.checkService('grpc', 'test', { diff --git a/lib/health-indicator/microservice/grpc.health.ts b/lib/health-indicator/microservice/grpc.health.ts index 8d85f4a90..304e759ed 100644 --- a/lib/health-indicator/microservice/grpc.health.ts +++ b/lib/health-indicator/microservice/grpc.health.ts @@ -2,12 +2,7 @@ import { join } from 'path'; import { Injectable, Scope } from '@nestjs/common'; import type * as NestJSMicroservices from '@nestjs/microservices'; import { type Observable } from 'rxjs'; -import { - type HealthIndicatorResult, - TimeoutError, - UnhealthyResponseCodeError, -} from '../..'; -import { HealthCheckError } from '../../health-check/health-check.error'; +import { type HealthIndicatorResult } from '../..'; import { checkPackages, isError, @@ -16,6 +11,7 @@ import { TimeoutError as PromiseTimeoutError, } from '../../utils'; import { HealthIndicator } from '../health-indicator'; +import { HealthIndicatorService } from '../health-indicator.service'; /** * The status of the request service @@ -100,7 +96,7 @@ export class GRPCHealthIndicator extends HealthIndicator { /** * Initializes the health indicator */ - constructor() { + constructor(private readonly healthIndicatorService: HealthIndicatorService) { super(); this.checkDependantPackages(); } @@ -142,6 +138,23 @@ export class GRPCHealthIndicator extends HealthIndicator { }); } + getHealthService( + service: string, + settings: CheckGRPCServiceOptions, + ) { + if (this.openChannels.has(service)) { + return this.openChannels.get(service)!; + } + + const client = this.createClient(settings); + const healthService = client.getService( + settings.healthServiceName as string, + ); + + this.openChannels.set(service, healthService); + return healthService; + } + /** * Checks if the given service is up using the standard health check * specification of GRPC. @@ -180,11 +193,14 @@ export class GRPCHealthIndicator extends HealthIndicator { */ async checkService< GrpcOptions extends GrpcClientOptionsLike = GrpcClientOptionsLike, + Key extends string = string, >( - key: string, + key: Key, service: string, options: CheckGRPCServiceOptions = {}, - ): Promise { + ): Promise> { + const check = this.healthIndicatorService.check(key); + const defaultOptions: CheckGRPCServiceOptions = { package: 'grpc.health.v1', protoPath: join(__dirname, './protos/health.proto'), @@ -199,31 +215,19 @@ export class GRPCHealthIndicator extends HealthIndicator { let healthService: GRPCHealthService; try { - if (this.openChannels.has(service)) { - healthService = this.openChannels.get(service)!; - } else { - const client = this.createClient(settings); - - healthService = client.getService( - settings.healthServiceName as string, - ); - - this.openChannels.set(service, healthService); - } + healthService = this.getHealthService(service, settings); } catch (err) { if (err instanceof TypeError) { throw err; } if (isError(err)) { - throw new HealthCheckError( - err.message, - this.getStatus(key, false, { message: err.message }), - ); + return check.down(err.message); } - throw new HealthCheckError( - err as any, - this.getStatus(key, false, { message: err as any }), - ); + if (typeof err === 'string') { + return check.down(err); + } + + return check.down(); } let response: HealthCheckResponse; @@ -238,39 +242,30 @@ export class GRPCHealthIndicator extends HealthIndicator { ); } catch (err) { if (err instanceof PromiseTimeoutError) { - throw new TimeoutError( - settings.timeout as number, - this.getStatus(key, false, { - message: `timeout of ${settings.timeout}ms exceeded`, - }), - ); + return check.down(`timeout of ${settings.timeout}ms exceeded`); } if (isError(err)) { - throw new HealthCheckError( - err.message, - this.getStatus(key, false, { message: err.message }), - ); + return check.down(err.message); } - throw new HealthCheckError( - err as any, - this.getStatus(key, false, { message: err as any }), - ); + if (typeof err === 'string') { + return check.down(err); + } + + return check.down(); } const isHealthy = response.status === ServingStatus.SERVING; - const status = this.getStatus(key, isHealthy, { - statusCode: response.status, - servingStatus: ServingStatus[response.status], - }); - if (!isHealthy) { - throw new UnhealthyResponseCodeError( - `${response.status}, ${ServingStatus[response.status]}`, - status, - ); + return check.down({ + statusCode: response.status, + servingStatus: ServingStatus[response.status], + }); } - return status; + return check.up({ + statusCode: response.status, + servingStatus: ServingStatus[response.status], + }); } } diff --git a/lib/health-indicator/microservice/microservice.health.ts b/lib/health-indicator/microservice/microservice.health.ts index b4eb55271..fe2dc38da 100644 --- a/lib/health-indicator/microservice/microservice.health.ts +++ b/lib/health-indicator/microservice/microservice.health.ts @@ -1,8 +1,6 @@ import { Injectable, Scope } from '@nestjs/common'; import type * as NestJSMicroservices from '@nestjs/microservices'; import { HealthIndicator, type HealthIndicatorResult } from '../'; -import { TimeoutError } from '../../errors'; -import { HealthCheckError } from '../../health-check/health-check.error'; import { checkPackages, promiseTimeout, @@ -10,6 +8,7 @@ import { type PropType, isError, } from '../../utils'; +import { HealthIndicatorService } from '../health-indicator.service'; // Since @nestjs/microservices is lazily loaded we are not able to use // its types. It would end up in the d.ts file if we would use the types. @@ -45,10 +44,8 @@ export type MicroserviceHealthIndicatorOptions< @Injectable({ scope: Scope.TRANSIENT }) export class MicroserviceHealthIndicator extends HealthIndicator { private nestJsMicroservices!: typeof NestJSMicroservices; - /** - * Initializes the health indicator - */ - constructor() { + + constructor(private readonly healthIndicatorService: HealthIndicatorService) { super(); this.checkDependantPackages(); } @@ -76,34 +73,6 @@ export class MicroserviceHealthIndicator extends HealthIndicator { return await checkConnection(); } - /** - * Prepares and throw a HealthCheckError - * @param key The key which will be used for the result object - * @param error The thrown error - * @param timeout The timeout in ms - * - * @throws {HealthCheckError} - */ - private generateError(key: string, error: Error, timeout: number) { - if (!error) { - return; - } - if (error instanceof PromiseTimeoutError) { - throw new TimeoutError( - timeout, - this.getStatus(key, false, { - message: `timeout of ${timeout}ms exceeded`, - }), - ); - } - throw new HealthCheckError( - error.message, - this.getStatus(key, false, { - message: error.message, - }), - ); - } - /** * Checks if the given microservice is up * @param key The key which will be used for the result object @@ -117,11 +86,14 @@ export class MicroserviceHealthIndicator extends HealthIndicator { * options: { host: 'localhost', port: 3001 }, * }) */ - async pingCheck( - key: string, + async pingCheck< + MicroserviceClientOptions extends MicroserviceOptionsLike, + Key extends string = string, + >( options: MicroserviceHealthIndicatorOptions, - ): Promise { - let isHealthy = false; + key: Key, + ): Promise> { + const check = this.healthIndicatorService.check(key); const timeout = options.timeout || 1000; if (options.transport === this.nestJsMicroservices.Transport.KAFKA) { @@ -135,20 +107,17 @@ export class MicroserviceHealthIndicator extends HealthIndicator { try { await promiseTimeout(timeout, this.pingMicroservice(options)); - isHealthy = true; } catch (err) { + if (err instanceof PromiseTimeoutError) { + return check.down(`timeout of ${timeout}ms exceeded`); + } if (isError(err)) { - this.generateError(key, err, timeout); + return check.down(err.message); } - const errorMsg = `${key} is not available`; - - throw new HealthCheckError( - errorMsg, - this.getStatus(key, false, { message: errorMsg }), - ); + return check.down(); } - return this.getStatus(key, isHealthy); + return check.up(); } } diff --git a/lib/terminus.module.ts b/lib/terminus.module.ts index 5cc1ebb66..03b51123d 100644 --- a/lib/terminus.module.ts +++ b/lib/terminus.module.ts @@ -9,6 +9,7 @@ import { ERROR_LOGGERS } from './health-check/error-logger/error-loggers.provide import { HealthCheckExecutor } from './health-check/health-check-executor.service'; import { getLoggerProvider } from './health-check/logger/logger.provider'; import { DiskUsageLibProvider } from './health-indicator/disk/disk-usage-lib.provider'; +import { HealthIndicatorService } from './health-indicator/health-indicator.service'; import { HEALTH_INDICATORS } from './health-indicator/health-indicators.provider'; import { type TerminusModuleOptions } from './terminus-options.interface'; @@ -17,6 +18,7 @@ const baseProviders: Provider[] = [ DiskUsageLibProvider, HealthCheckExecutor, HealthCheckService, + HealthIndicatorService, ...HEALTH_INDICATORS, ]; diff --git a/lib/utils/promise-timeout.ts b/lib/utils/promise-timeout.ts index 2daf133c3..aaf1ea041 100644 --- a/lib/utils/promise-timeout.ts +++ b/lib/utils/promise-timeout.ts @@ -16,12 +16,12 @@ export class TimeoutError extends Error {} * * @internal */ -export const promiseTimeout = function ( +export const promiseTimeout = ( ms: number, - promise: Promise, -): Promise { + promise: Promise, +): Promise => { let timer: NodeJS.Timeout; - return Promise.race([ + return Promise.race([ promise, new Promise( (_, reject) =>