Skip to content
Closed
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
88 changes: 88 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
58 changes: 58 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
128 changes: 128 additions & 0 deletions src/__tests__/healthProbes.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
50 changes: 49 additions & 1 deletion src/api/rest.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
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'
import { config, getNetworkConfig, activeNetwork, type NetworkName } from '../config'
import '../middleware/network' // declares req.network on the FastifyRequest type
import {
statusResponseSchema,
liveResponseSchema,
readyResponseSchema,
priceResponseSchema,
routeResponseSchema,
historyResponseSchema,
poolsResponseSchema,
depthResponseSchema,
installResponseValidation,
} from './schemas'
import { isBootCompleted } from '../bootState'
import { getDepth } from '../pricing/depth'

function makePairKey(a: string, b: string): string {
Expand Down Expand Up @@ -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
Expand Down
Loading