From a009e5ae329b2a01d8114bc92f80197c1829a804 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:50:59 +0100 Subject: [PATCH] fix: enhance health check functionality with detailed memory diagnostics and stable liveness probe --- app/api/health/route.ts | 61 ++++++++----------- lib/__tests__/health-route.test.ts | 98 ++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 35 deletions(-) create mode 100644 lib/__tests__/health-route.test.ts diff --git a/app/api/health/route.ts b/app/api/health/route.ts index ce374ac2..b7b1c52d 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,10 +1,21 @@ +import v8 from 'node:v8'; import { NextResponse } from 'next/server'; import { NextRequest } from 'next/server'; import { logger } from '@/lib/logger'; -// Health check thresholds -const MEMORY_WARNING_THRESHOLD = 0.85; // 85% heap usage -const MEMORY_CRITICAL_THRESHOLD = 0.95; // 95% heap usage +const MEMORY_WARNING_THRESHOLD = 0.85; +const MEMORY_CRITICAL_THRESHOLD = 0.95; + +function getHeapUsagePercent(heapUsed: number, heapTotal: number): number { + const heapSizeLimit = v8.getHeapStatistics().heap_size_limit; + const denominator = heapSizeLimit > 0 ? heapSizeLimit : heapTotal; + + if (denominator <= 0) { + return 0; + } + + return (heapUsed / denominator) * 100; +} interface HealthStatus { status: 'healthy' | 'degraded' | 'unhealthy'; @@ -14,6 +25,7 @@ interface HealthStatus { memory?: { heapUsed: number; heapTotal: number; + heapSizeLimit: number; rss: number; external: number; heapUsagePercent: number; @@ -27,14 +39,9 @@ interface HealthStatus { /** * Health check endpoint for container orchestration * - * GET /api/health - Basic health check (returns 200 OK or 503 Service Unavailable) - * GET /api/health?detailed=true - Detailed diagnostics with memory stats - * HEAD /api/health - Lightweight health check (status code only) - * - * Health status based on Node.js heap usage: - * - Healthy (200): < 85% heap usage - * - Degraded (200): 85-95% heap usage (warnings in detailed mode) - * - Unhealthy (503): > 95% heap usage + * GET /api/health - Liveness probe for container orchestration + * GET /api/health?detailed=true - Diagnostics with advisory memory warnings + * HEAD /api/health - Lightweight liveness probe (status code only) */ export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; @@ -43,38 +50,31 @@ export async function GET(request: NextRequest) { try { const timestamp = new Date().toISOString(); const memUsage = process.memoryUsage(); - const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100; - - // Determine health status based on memory usage + const heapSizeLimit = v8.getHeapStatistics().heap_size_limit; + const heapUsagePercent = getHeapUsagePercent(memUsage.heapUsed, memUsage.heapTotal); let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy'; const warnings: string[] = []; - let httpStatus = 200; if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) { - status = 'unhealthy'; - httpStatus = 503; + status = 'degraded'; + warnings.push(`V8 heap usage is very high: ${heapUsagePercent.toFixed(1)}% of heap limit`); } else if (heapUsagePercent >= MEMORY_WARNING_THRESHOLD * 100) { status = 'degraded'; - warnings.push(`Memory usage high: ${heapUsagePercent.toFixed(1)}%`); + warnings.push(`V8 heap usage is high: ${heapUsagePercent.toFixed(1)}% of heap limit`); } - // Build response const response: HealthStatus = { - status, + status: detailed ? status : 'healthy', timestamp, }; - if (status === 'unhealthy') { - response.reason = `Memory usage critical: ${heapUsagePercent.toFixed(1)}%`; - } - - // Add detailed information if requested if (detailed) { response.uptime = process.uptime(); response.version = process.env.npm_package_version || '0.1.0'; response.memory = { heapUsed: memUsage.heapUsed, heapTotal: memUsage.heapTotal, + heapSizeLimit, rss: memUsage.rss, external: memUsage.external, heapUsagePercent: Number(heapUsagePercent.toFixed(2)), @@ -87,10 +87,8 @@ export async function GET(request: NextRequest) { } } - logger.info('Health check', { status, detailed }); - return NextResponse.json(response, { - status: httpStatus, + status: 200, headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate', 'Pragma': 'no-cache', @@ -116,13 +114,6 @@ export async function GET(request: NextRequest) { */ export async function HEAD() { try { - const memUsage = process.memoryUsage(); - const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100; - - if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) { - return new Response(null, { status: 503 }); - } - return new Response(null, { status: 200 }); } catch { return new Response(null, { status: 503 }); diff --git a/lib/__tests__/health-route.test.ts b/lib/__tests__/health-route.test.ts new file mode 100644 index 00000000..2daee56f --- /dev/null +++ b/lib/__tests__/health-route.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const loggerError = vi.fn(); + +vi.mock('next/server', () => ({ + NextResponse: { + json: (data: unknown, init?: { status?: number; headers?: unknown }) => ({ + status: init?.status ?? 200, + headers: init?.headers, + json: async () => data, + }), + }, +})); + +vi.mock('@/lib/logger', () => ({ + logger: { + error: loggerError, + }, +})); + +describe('health route', () => { + beforeEach(() => { + vi.restoreAllMocks(); + loggerError.mockReset(); + }); + + it('returns healthy for the basic liveness probe even when heap usage is high', async () => { + vi.spyOn(process, 'memoryUsage').mockReturnValue({ + rss: 120_000_000, + heapTotal: 45_000_000, + heapUsed: 43_000_000, + external: 8_000_000, + arrayBuffers: 1_000_000, + }); + + const { GET } = await import('@/app/api/health/route'); + const response = await GET({ nextUrl: new URL('http://localhost/api/health') } as never); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload).toMatchObject({ + status: 'healthy', + }); + expect(payload.warnings).toBeUndefined(); + }); + + it('returns degraded diagnostics in detailed mode without failing the probe', async () => { + vi.spyOn(process, 'memoryUsage').mockReturnValue({ + rss: 120_000_000, + heapTotal: 4_100_000_000, + heapUsed: 4_000_000_000, + external: 8_000_000, + arrayBuffers: 1_000_000, + }); + vi.spyOn(process, 'uptime').mockReturnValue(123.45); + + const { GET } = await import('@/app/api/health/route'); + const response = await GET({ nextUrl: new URL('http://localhost/api/health?detailed=true') } as never); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.status).toBe('degraded'); + expect(payload.memory).toMatchObject({ + heapUsed: 4_000_000_000, + heapTotal: 4_100_000_000, + rss: 120_000_000, + external: 8_000_000, + }); + expect(payload.memory.heapSizeLimit).toBeGreaterThan(0); + expect(payload.warnings).toEqual([ + expect.stringContaining('V8 heap usage is high'), + ]); + }); + + it('keeps HEAD as a stable liveness probe', async () => { + const { HEAD } = await import('@/app/api/health/route'); + const response = await HEAD(); + + expect(response.status).toBe(200); + }); + + it('returns 503 when collecting health diagnostics throws', async () => { + vi.spyOn(process, 'memoryUsage').mockImplementation(() => { + throw new Error('boom'); + }); + + const { GET } = await import('@/app/api/health/route'); + const response = await GET({ nextUrl: new URL('http://localhost/api/health') } as never); + const payload = await response.json(); + + expect(response.status).toBe(503); + expect(payload).toMatchObject({ + status: 'unhealthy', + reason: 'boom', + }); + expect(loggerError).toHaveBeenCalledWith('Health check failed', { error: 'boom' }); + }); +}); \ No newline at end of file