From 715f6d3c81b9a35973da8d2243e420df732beb0f Mon Sep 17 00:00:00 2001 From: sparkysolutions10-pixelmy Date: Mon, 19 Oct 2026 10:20:57 +0530 Subject: [PATCH] feat(health): implement GET /live and /ready health probes (#334) --- openapi.json | 88 ++++++++++++++++++++ openapi.yaml | 58 +++++++++++++ src/__tests__/healthProbes.test.ts | 128 +++++++++++++++++++++++++++++ src/api/rest.ts | 50 ++++++++++- src/api/schemas.ts | 28 +++++++ src/app.ts | 2 +- src/bootState.ts | 20 +++++ src/middleware/httpMetrics.ts | 2 +- 8 files changed, 373 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/healthProbes.test.ts create mode 100644 src/bootState.ts diff --git a/openapi.json b/openapi.json index 94cb0a74..943727a5 100644 --- a/openapi.json +++ b/openapi.json @@ -168,6 +168,94 @@ } } }, + "/live": { + "get": { + "operationId": "getLive", + "summary": "Liveness check probe", + "security": [], + "responses": { + "200": { + "description": "Process event loop is healthy and alive", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "live" + } + } + } + } + } + } + } + } + }, + "/ready": { + "get": { + "operationId": "getReady", + "summary": "Readiness check probe", + "security": [], + "responses": { + "200": { + "description": "Process has finished booting and dependencies are ready", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "ready" + }, + "checks": { + "type": "object", + "properties": { + "database": { + "type": "boolean" + }, + "redis": { + "type": "boolean" + } + } + } + } + } + } + } + }, + "503": { + "description": "Process is starting up or dependencies are unreachable", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "unhealthy" + }, + "checks": { + "type": "object", + "properties": { + "database": { + "type": "boolean" + }, + "redis": { + "type": "boolean" + } + } + } + } + } + } + } + } + } + } + }, "/price/{assetA}/{assetB}": { "get": { "operationId": "getPrice", diff --git a/openapi.yaml b/openapi.yaml index eb49b630..96a4f385 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -117,6 +117,64 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' + /live: + get: + operationId: getLive + summary: Liveness check probe + security: [] + responses: + '200': + description: Process event loop is healthy and alive + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: live + + /ready: + get: + operationId: getReady + summary: Readiness check probe + security: [] + responses: + '200': + description: Process has finished booting and dependencies are ready + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: ready + checks: + type: object + properties: + database: + type: boolean + redis: + type: boolean + '503': + description: Process is starting up or dependencies are unreachable + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: unhealthy + checks: + type: object + properties: + database: + type: boolean + redis: + type: boolean + /price/{assetA}/{assetB}: get: operationId: getPrice diff --git a/src/__tests__/healthProbes.test.ts b/src/__tests__/healthProbes.test.ts new file mode 100644 index 00000000..3937cf34 --- /dev/null +++ b/src/__tests__/healthProbes.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { FastifyInstance } from 'fastify' + +const mockQuery = vi.fn() +const mockRedisPing = vi.fn() + +vi.mock('../db', () => ({ + pgPool: { + query: (...args: any[]) => mockQuery(...args), + }, + prisma: { + apiKey: { + findUnique: vi.fn(), + }, + }, +})) + +vi.mock('../redis', () => ({ + redis: { + status: 'ready', + ping: () => mockRedisPing(), + }, + getCachedPrice: vi.fn(), + setCachedPrice: vi.fn(), +})) + +import { buildApp } from '../app' +import { setBootComplete, resetBootStateForTesting } from '../bootState' + +describe('Health Probes Contract: /live and /ready (#334)', () => { + let app: FastifyInstance + + beforeEach(async () => { + mockQuery.mockReset() + mockRedisPing.mockReset() + resetBootStateForTesting() + app = await buildApp() + await app.ready() + }) + + afterEach(async () => { + resetBootStateForTesting() + await app.close() + }) + + it('/live returns 200 without querying database or redis', async () => { + const res = await app.inject({ + method: 'GET', + url: '/live', + }) + + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ status: 'live' }) + expect(mockQuery).not.toHaveBeenCalled() + expect(mockRedisPing).not.toHaveBeenCalled() + }) + + it('/ready returns 503 before boot completes, and 200 after boot completes', async () => { + mockQuery.mockResolvedValue({ rows: [{ '?column?': 1 }] }) + mockRedisPing.mockResolvedValue('PONG') + + // 1. Before boot complete + const res1 = await app.inject({ + method: 'GET', + url: '/ready', + }) + expect(res1.statusCode).toBe(503) + expect(res1.json()).toEqual({ + status: 'unhealthy', + checks: { + database: true, + redis: true, + }, + }) + + // 2. Mark boot complete + setBootComplete(true) + const res2 = await app.inject({ + method: 'GET', + url: '/ready', + }) + expect(res2.statusCode).toBe(200) + expect(res2.json()).toEqual({ + status: 'ready', + checks: { + database: true, + redis: true, + }, + }) + }) + + it('/ready reports 503 when Postgres is unreachable even if boot is complete', async () => { + setBootComplete(true) + mockQuery.mockRejectedValueOnce(new Error('Connection refused')) + mockRedisPing.mockResolvedValue('PONG') + + const res = await app.inject({ + method: 'GET', + url: '/ready', + }) + + expect(res.statusCode).toBe(503) + const body = res.json() + expect(body.status).toBe('unhealthy') + expect(body.checks.database).toBe(false) + }) + + it('/status remains unchanged and functional', async () => { + mockQuery.mockResolvedValueOnce({ + rows: [ + { + last_ledger: 500000, + last_processed_at: new Date(), + }, + ], + }) + + const res = await app.inject({ + method: 'GET', + url: '/status', + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.ok).toBe(true) + expect(body.lastIndexedLedger).toBe(500000) + }) +}) diff --git a/src/api/rest.ts b/src/api/rest.ts index 2f5fbdce..3d5df133 100644 --- a/src/api/rest.ts +++ b/src/api/rest.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from 'fastify' import { price_requests_total } from '../metrics' -import { getCachedPrice, setCachedPrice } from '../redis' +import { redis, getCachedPrice, setCachedPrice } from '../redis' import { getAggregatedPrice } from '../aggregator/vwap' import { getBestRoute } from '../aggregator/bestRoute' import { pgPool } from '../db' @@ -8,6 +8,8 @@ import { config, getNetworkConfig, activeNetwork, type NetworkName } from '../co import '../middleware/network' // declares req.network on the FastifyRequest type import { statusResponseSchema, + liveResponseSchema, + readyResponseSchema, priceResponseSchema, routeResponseSchema, historyResponseSchema, @@ -15,6 +17,7 @@ import { depthResponseSchema, installResponseValidation, } from './schemas' +import { isBootCompleted } from '../bootState' import { getDepth } from '../pricing/depth' function makePairKey(a: string, b: string): string { @@ -68,6 +71,51 @@ export async function registerRESTRoutes(app: FastifyInstance) { // up the validating serializer. installResponseValidation(app) + // GET /live — lightweight liveness probe (#334) + app.get('/live', { config: { public: true }, schema: { response: { 200: liveResponseSchema } } }, async () => { + return { status: 'live' } + }) + + // GET /ready — readiness probe checking boot lifecycle and dependencies (#334) + app.get( + '/ready', + { config: { public: true }, schema: { response: { 200: readyResponseSchema, 503: readyResponseSchema } } }, + async (_req, reply) => { + let dbOk = false + try { + await pgPool.query('SELECT 1') + dbOk = true + } catch { + dbOk = false + } + + let redisOk = false + try { + if (redis.status === 'ready' || redis.status === 'connect') { + redisOk = true + } else { + await redis.ping() + redisOk = true + } + } catch { + redisOk = false + } + + const ready = isBootCompleted() && dbOk + const body = { + status: ready ? ('ready' as const) : ('unhealthy' as const), + checks: { + database: dbOk, + redis: redisOk, + }, + } + if (!ready) { + return reply.status(503).send(body) + } + return body + } + ) + // GET /status — public health/monitoring endpoint (no API key required) app.get('/status', { config: { public: true }, schema: { response: { 200: statusResponseSchema } } }, async (req) => { const network = req.network ?? activeNetwork diff --git a/src/api/schemas.ts b/src/api/schemas.ts index c7db8496..0f6ec335 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -36,6 +36,34 @@ export const statusResponseSchema = { }, } as const +/** GET /live (#334) */ +export const liveResponseSchema = { + type: 'object', + required: ['status'], + additionalProperties: false, + properties: { + status: { type: 'string', const: 'live' }, + }, +} as const + +/** GET /ready (#334) */ +export const readyResponseSchema = { + type: 'object', + required: ['status', 'checks'], + additionalProperties: false, + properties: { + status: { type: 'string', enum: ['ready', 'unhealthy'] }, + checks: { + type: 'object', + required: ['database', 'redis'], + properties: { + database: { type: 'boolean' }, + redis: { type: 'boolean' }, + }, + }, + }, +} as const + /** GET /price/:assetA/:assetB */ export const priceResponseSchema = { type: 'object', diff --git a/src/app.ts b/src/app.ts index 671dac37..82d1eed2 100644 --- a/src/app.ts +++ b/src/app.ts @@ -102,7 +102,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise req.apiKey?.ratePerMin ?? ipRateLimitMax, timeWindow: '1 minute', keyGenerator: (req) => req.apiKey?.id ?? req.ip, - allowList: (req) => req.url === '/status', + allowList: (req) => req.url === '/status' || req.url === '/live' || req.url === '/ready', errorResponseBuilder: (req, context) => ({ statusCode: 429, error: 'Too Many Requests', diff --git a/src/bootState.ts b/src/bootState.ts new file mode 100644 index 00000000..2d669370 --- /dev/null +++ b/src/bootState.ts @@ -0,0 +1,20 @@ +/** + * Application Lifecycle & Boot Readiness State (#334) + * + * Tracks whether initialization (migrations, connections, and worker scheduling) + * has completed, allowing /ready to report 503 during startup and 200 once ready. + */ + +let bootCompleted = false + +export function setBootComplete(completed = true): void { + bootCompleted = completed +} + +export function isBootCompleted(): boolean { + return bootCompleted +} + +export function resetBootStateForTesting(): void { + bootCompleted = false +} diff --git a/src/middleware/httpMetrics.ts b/src/middleware/httpMetrics.ts index 82ada904..d0d0247e 100644 --- a/src/middleware/httpMetrics.ts +++ b/src/middleware/httpMetrics.ts @@ -14,7 +14,7 @@ interface TimedRequest extends FastifyRequest { * so counting it would have the scraper continuously inflate the very numbers * it is reading — request rate would never fall to zero on an idle service. */ -const EXCLUDED_ROUTES = new Set(['/metrics']) +const EXCLUDED_ROUTES = new Set(['/metrics', '/live', '/ready']) /** * Bucket a status code into a class label. Keeping 2xx/4xx/5xx rather than the