diff --git a/lib/health-check/health-check-executor.service.spec.ts b/lib/health-check/health-check-executor.service.spec.ts index 6aeda4ff1..9c2e1cb8c 100644 --- a/lib/health-check/health-check-executor.service.spec.ts +++ b/lib/health-check/health-check-executor.service.spec.ts @@ -1,11 +1,12 @@ import { Test } from '@nestjs/testing'; -import { HealthCheckExecutor } from './health-check-executor.service'; +import { HealthCheckError } from '../health-check/health-check.error'; import { HealthIndicatorResult, HealthIndicatorService, } from '../health-indicator'; +import { HealthCheckExecutor } from './health-check-executor.service'; import { HealthCheckResult } from './health-check-result.interface'; -import { HealthCheckError } from '../health-check/health-check.error'; +import { TERMINUS_FAIL_READINESS_ON_SHUTDOWN } from './shutdown.constants'; //////////////////////////////////////////////////////////////// @@ -266,5 +267,45 @@ describe('HealthCheckExecutorService', () => { }); }); }); + + describe('shutdown behavior', () => { + it('should return status "error" while shutting down when failReadinessOnShutdown is enabled', async () => { + const moduleRef = await Test.createTestingModule({ + providers: [ + HealthCheckExecutor, + HealthIndicatorService, + { provide: TERMINUS_FAIL_READINESS_ON_SHUTDOWN, useValue: true }, + ], + }).compile(); + + const exec = moduleRef.get(HealthCheckExecutor); + const svc = moduleRef.get(HealthIndicatorService); + + exec.beforeApplicationShutdown(); // simulate SIGTERM + const result = await exec.execute([() => healthIndicator(svc)]); + + expect(result.status).toBe('error'); + // indicators still execute; info should contain healthy result + expect(result.info).toHaveProperty('healthy.status', 'up'); + // overall details reflect the indicator outcome + expect(result.details).toHaveProperty('healthy.status', 'up'); + }); + + it('should return status "shutting_down" while shutting down when failReadinessOnShutdown is disabled', async () => { + const moduleRef = await Test.createTestingModule({ + providers: [HealthCheckExecutor, HealthIndicatorService], + }).compile(); + + const exec = moduleRef.get(HealthCheckExecutor); + const svc = moduleRef.get(HealthIndicatorService); + + exec.beforeApplicationShutdown(); // simulate SIGTERM + const result = await exec.execute([() => healthIndicator(svc)]); + + expect(result.status).toBe('shutting_down'); + // indicator still ran + expect(result.info).toHaveProperty('healthy.status', 'up'); + }); + }); }); }); diff --git a/lib/health-check/health-check-executor.service.ts b/lib/health-check/health-check-executor.service.ts index 6f25c597a..21017e34b 100644 --- a/lib/health-check/health-check-executor.service.ts +++ b/lib/health-check/health-check-executor.service.ts @@ -1,14 +1,20 @@ -import { Injectable, type BeforeApplicationShutdown } from '@nestjs/common'; import { - type HealthCheckResult, - type HealthCheckStatus, -} from './health-check-result.interface'; + Inject, + Injectable, + Optional, + type BeforeApplicationShutdown, +} from '@nestjs/common'; import { type HealthCheckError } from '../health-check/health-check.error'; import { type HealthIndicatorFunction, type HealthIndicatorResult, } from '../health-indicator'; import { isHealthCheckError } from '../utils'; +import { + type HealthCheckResult, + type HealthCheckStatus, +} from './health-check-result.interface'; +import { TERMINUS_FAIL_READINESS_ON_SHUTDOWN } from './shutdown.constants'; /** * Takes care of the execution of health indicators. @@ -28,68 +34,74 @@ import { isHealthCheckError } from '../utils'; export class HealthCheckExecutor implements BeforeApplicationShutdown { private isShuttingDown = false; + constructor( + @Optional() + @Inject(TERMINUS_FAIL_READINESS_ON_SHUTDOWN) + private readonly failReadinessOnShutdown?: boolean, + ) {} + /** * Executes the given health indicators. - * Implementation for v6 compatibility. * * @throws {Error} All errors which are not inherited by the `HealthCheckError`-class - * - * @returns the result of given health indicators - * @param healthIndicators The health indicators which should get executed */ async execute( healthIndicators: HealthIndicatorFunction[], ): Promise { const { results, errors } = await this.executeHealthIndicators(healthIndicators); - return this.getResult(results, errors); } - /** - * @internal - */ - beforeApplicationShutdown(): void { + /** @internal */ + public beforeApplicationShutdown(): void { this.isShuttingDown = true; } private async executeHealthIndicators( healthIndicators: HealthIndicatorFunction[], - ) { + ): Promise<{ + results: HealthIndicatorResult[]; + errors: HealthIndicatorResult[]; + }> { const results: HealthIndicatorResult[] = []; const errors: HealthIndicatorResult[] = []; - const result = await Promise.allSettled( - healthIndicators.map(async (h) => h()), + // Important: wrap each call so sync throws become rejections captured by allSettled + const settled = await Promise.allSettled( + healthIndicators.map(async (fn) => await fn()), ); - result.forEach((res) => { + for (const res of settled) { if (res.status === 'fulfilled') { - Object.entries(res.value).forEach(([key, value]) => { - if (value.status === 'up') { - results.push({ [key]: value }); - } else if (value.status === 'down') { - errors.push({ [key]: value }); - } - }); + const value = res.value as HealthIndicatorResult; + + // If any entry in the fulfilled result is 'down', treat it as an error bucket + if (this.isDown(value)) { + errors.push(value); + } else { + results.push(value); + } } else { - const error = res.reason; - // Is not an expected error. Throw further! - if (!isHealthCheckError(error)) { - throw error; + const err = res.reason; + + // If it isn't a typed HealthCheckError, rethrow (test suite expects this behavior) + if (!isHealthCheckError(err)) { + throw err; } - // eslint-disable-next-line deprecation/deprecation - errors.push((error as HealthCheckError).causes); + // HealthCheckError carries a "causes" object in the same shape as a result + errors.push((err as HealthCheckError).causes); } - }); + } return { results, errors }; } private getSummary(results: HealthIndicatorResult[]): HealthIndicatorResult { return results.reduce( - (previous: any, current: any) => Object.assign(previous, current), + (previous: Record, current: Record) => + Object.assign(previous, current), {}, ); } @@ -104,9 +116,21 @@ export class HealthCheckExecutor implements BeforeApplicationShutdown { const error = this.getSummary(errors); const details = this.getSummary(infoErrorCombined); + // Status precedence: + // 1) If shutting down AND failReadinessOnShutdown => 'error' (HTTP 503) + // 2) Else if any errors present => 'error' + // 3) Else if shutting down => 'shutting_down' + // 4) Else 'ok' let status: HealthCheckStatus = 'ok'; - status = errors.length > 0 ? 'error' : status; - status = this.isShuttingDown ? 'shutting_down' : status; + + if (this.isShuttingDown && this.failReadinessOnShutdown) { + status = 'error'; + } else if (errors.length > 0) { + status = 'error'; + } else if (this.isShuttingDown) { + // legacy behavior kept for back-compat when the flag is off + status = 'shutting_down' as HealthCheckStatus; + } return { status, @@ -115,4 +139,11 @@ export class HealthCheckExecutor implements BeforeApplicationShutdown { details, }; } + + private isDown(result: HealthIndicatorResult): boolean { + // result shape: { indicatorName: { status: 'up' | 'down', ... }, ... } + return Object.values(result).some( + (entry: any) => entry?.status && entry.status !== 'up', + ); + } } diff --git a/lib/health-check/shutdown.constants.ts b/lib/health-check/shutdown.constants.ts new file mode 100644 index 000000000..332cedf02 --- /dev/null +++ b/lib/health-check/shutdown.constants.ts @@ -0,0 +1,2 @@ +export const TERMINUS_FAIL_READINESS_ON_SHUTDOWN = + 'TERMINUS_FAIL_READINESS_ON_SHUTDOWN'; diff --git a/lib/index.ts b/lib/index.ts index bead16219..8baa7b8d6 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,12 +1,12 @@ -export { TerminusModule } from './terminus.module'; -export { TerminusModuleOptions } from './terminus-options.interface'; -export * from './health-indicator'; export * from './errors'; export { HealthCheck, - HealthCheckService, // eslint-disable-next-line deprecation/deprecation HealthCheckError, - HealthCheckStatus, HealthCheckResult, + HealthCheckService, + HealthCheckStatus, } from './health-check'; +export * from './health-indicator'; +export { TerminusModuleOptions } from './terminus-options.interface'; +export { TerminusModule } from './terminus.module'; diff --git a/lib/terminus-options.interface.ts b/lib/terminus-options.interface.ts index 06339328e..60515fcf8 100644 --- a/lib/terminus-options.interface.ts +++ b/lib/terminus-options.interface.ts @@ -25,4 +25,11 @@ export interface TerminusModuleOptions { * @default 0 */ gracefulShutdownTimeoutMs?: number; + + /** + * When true, all @HealthCheck() endpoints return 503 once shutdown begins. + * Combine with gracefulShutdownTimeoutMs to drain traffic before closing. + * Default: false + */ + failReadinessOnShutdown?: boolean; } diff --git a/lib/terminus.module.ts b/lib/terminus.module.ts index 1cdec9a24..37bce34ce 100644 --- a/lib/terminus.module.ts +++ b/lib/terminus.module.ts @@ -8,6 +8,7 @@ import { getErrorLoggerProvider } from './health-check/error-logger/error-logger import { ERROR_LOGGERS } from './health-check/error-logger/error-loggers.provider'; import { HealthCheckExecutor } from './health-check/health-check-executor.service'; import { getLoggerProvider } from './health-check/logger/logger.provider'; +import { TERMINUS_FAIL_READINESS_ON_SHUTDOWN } from './health-check/shutdown.constants'; 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'; @@ -34,6 +35,7 @@ const exports_ = [ * * @publicApi */ + @Module({ providers: [...baseProviders, getErrorLoggerProvider(), getLoggerProvider()], exports: exports_, @@ -44,6 +46,7 @@ export class TerminusModule { errorLogStyle = 'json', logger = true, gracefulShutdownTimeoutMs = 0, + failReadinessOnShutdown = false, } = options; const providers: Provider[] = [ @@ -57,14 +60,16 @@ export class TerminusModule { provide: TERMINUS_GRACEFUL_SHUTDOWN_TIMEOUT, useValue: gracefulShutdownTimeoutMs, }); - providers.push(GracefulShutdownService); } - return { - module: TerminusModule, - providers, - exports: exports_, - }; + if (failReadinessOnShutdown) { + providers.push({ + provide: TERMINUS_FAIL_READINESS_ON_SHUTDOWN, + useValue: true, + }); + } + + return { module: TerminusModule, providers, exports: exports_ }; } }