diff --git a/backend/src/index.ts b/backend/src/index.ts index 2c96d379..c9dbba3f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -15,6 +15,7 @@ import { setRateLimitEnvOverrides } from './config/rateLimit.config.js'; import { swaggerSpec } from './config/swagger.js'; import type { CorsRequest } from 'cors'; import prisma from './db/index.js'; +import { checkDbHealth } from './db/healthMonitor.js'; import { createGraphQLServer } from './graphql/server.js'; import { scheduleBackupCron, startBackupWorker, stopBackupWorker } from './jobs/backup.worker.js'; import { dbRoutingMiddleware } from './middleware/dbRouting.js'; @@ -108,11 +109,83 @@ if (config.rateLimiting.enabled) { app.use(requestLogger); app.use(getSentryRequestHandler()); +function withTimeout(promise: Promise, ms: number): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)), + ]); +} + +/** + * @openapi + * /health/live: + * get: + * summary: Liveness probe + * description: Lightweight endpoint that returns 200 while the process is alive. No dependency checks. + * tags: [System] + * responses: + * 200: + * description: Process is alive + */ +app.get('/health/live', (_req: Request, res: Response) => { + res.json({ status: 'ok' }); +}); + +/** + * @openapi + * /health/ready: + * get: + * summary: Readiness probe + * description: Checks that required dependencies (PostgreSQL, Redis) are reachable. + * tags: [System] + * responses: + * 200: + * description: All dependencies healthy + * 503: + * description: One or more dependencies unavailable + */ +app.get('/health/ready', async (_req: Request, res: Response) => { + const errors: string[] = []; + + let dbStatus: Awaited> | null = null; + try { + dbStatus = await withTimeout(checkDbHealth(), 5000); + } catch { + errors.push('Database health check timed out or failed'); + } + + if (dbStatus && dbStatus.status === 'unhealthy') { + errors.push(`Database: ${dbStatus.alerts.join(', ')}`); + } + + const redisHealthy = redisClient.isHealthy(); + if (!redisHealthy) { + errors.push('Redis is disconnected'); + } + + if (errors.length > 0) { + return res.status(503).json({ + status: 'error', + errors, + uptime: process.uptime(), + }); + } + + res.json({ + status: 'ok', + uptime: process.uptime(), + database: dbStatus + ? { status: dbStatus.status, latencyMs: dbStatus.latencyMs } + : { status: 'skipped' }, + redis: redisHealthy ? 'connected' : 'disconnected', + }); +}); + /** * @openapi * /health: * get: - * summary: Health check endpoint + * summary: Health check endpoint (legacy) * description: Returns the health status of the API and its dependencies * tags: [System] * responses: @@ -139,7 +212,6 @@ app.use(getSentryRequestHandler()); * type: string * example: connected */ -// Health check endpoint app.get('/health', (_req: Request, res: Response) => { res.json({ status: 'ok', diff --git a/backend/tests/health.test.ts b/backend/tests/health.test.ts index 379340a8..320aac38 100644 --- a/backend/tests/health.test.ts +++ b/backend/tests/health.test.ts @@ -31,6 +31,38 @@ describe('Health Endpoint Integration Tests', () => { jest.clearAllMocks(); }); + describe('GET /health/live', () => { + it('should return 200 with status ok', async () => { + const response = await request(app).get('/health/live'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ status: 'ok' }); + }); + + it('should return JSON content type', async () => { + const response = await request(app).get('/health/live'); + + expect(response.headers['content-type']).toMatch(/application\/json/); + }); + }); + + describe('GET /health/ready', () => { + it('should return 503 when dependencies are unavailable in test', async () => { + const response = await request(app).get('/health/ready'); + + expect([200, 503]).toContain(response.status); + expect(response.body).toHaveProperty('uptime'); + if (response.status === 503) { + expect(response.body).toHaveProperty('errors'); + expect(Array.isArray(response.body.errors)).toBe(true); + } else { + expect(response.body).toHaveProperty('status', 'ok'); + expect(response.body).toHaveProperty('database'); + expect(response.body).toHaveProperty('redis'); + } + }); + }); + describe('GET /health', () => { it('should return 200 and health status', async () => { const response = await request(app).get('/health'); @@ -127,4 +159,4 @@ describe('Health Endpoint Integration Tests', () => { expect(response.status).toBe(404); }); }); -}); +}); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 938fafb8..355d080f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -181,6 +181,9 @@ services: condition: service_healthy redis: condition: service_healthy + # Liveness probe — lightweight, no dependency calls + # In Kubernetes, use separate livenessProbe (on /health/live) and + # readinessProbe (on /health/ready) healthcheck: # Readiness probe: container is healthy only when the API is running # AND database + Redis are available.