Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions lib/health-check/health-check-executor.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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';

////////////////////////////////////////////////////////////////

Expand Down Expand Up @@ -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');
});
});
});
});
99 changes: 65 additions & 34 deletions lib/health-check/health-check-executor.service.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<HealthCheckResult> {
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<string, any>, current: Record<string, any>) =>
Object.assign(previous, current),
{},
);
}
Expand All @@ -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,
Expand All @@ -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',
);
}
}
2 changes: 2 additions & 0 deletions lib/health-check/shutdown.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const TERMINUS_FAIL_READINESS_ON_SHUTDOWN =
'TERMINUS_FAIL_READINESS_ON_SHUTDOWN';
10 changes: 5 additions & 5 deletions lib/index.ts
Original file line number Diff line number Diff line change
@@ -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';
7 changes: 7 additions & 0 deletions lib/terminus-options.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
17 changes: 11 additions & 6 deletions lib/terminus.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -34,6 +35,7 @@ const exports_ = [
*
* @publicApi
*/

@Module({
providers: [...baseProviders, getErrorLoggerProvider(), getLoggerProvider()],
exports: exports_,
Expand All @@ -44,6 +46,7 @@ export class TerminusModule {
errorLogStyle = 'json',
logger = true,
gracefulShutdownTimeoutMs = 0,
failReadinessOnShutdown = false,
} = options;

const providers: Provider[] = [
Expand All @@ -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_ };
}
}