From 4d6dd457f4add8d8b8d787b63c57168431b3e6c3 Mon Sep 17 00:00:00 2001 From: Ivan Hell Date: Wed, 2 Sep 2026 19:34:35 +0200 Subject: [PATCH] feat: add /api/healthz and /api/readyz endpoints Adds two unauthenticated endpoints for container orchestrators (Kubernetes, Docker healthchecks, etc.): - /api/healthz: dependency-free liveness check, only confirms the Next.js server itself is responding. Intentionally never touches the database, so a broken/unreachable Postgres never causes cascading restarts across replicas. - /api/readyz: readiness check that verifies the database is reachable via a bounded SELECT 1, so load balancers can stop routing traffic to an instance while Postgres is down, without restarting the process. --- docs/CONFIGURATION.md | 2 + src/pages/api/healthz.ts | 16 ++++++ src/pages/api/readyz.ts | 39 +++++++++++++ src/tests/healthz.test.ts | 37 ++++++++++++ src/tests/readyz.test.ts | 118 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 212 insertions(+) create mode 100644 src/pages/api/healthz.ts create mode 100644 src/pages/api/readyz.ts create mode 100644 src/tests/healthz.test.ts create mode 100644 src/tests/readyz.test.ts diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 11d25d2a4..a8885f41f 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -15,6 +15,8 @@ This document lists SplitPro environment variables and how they are used. The au `POSTGRES_USER` may be a regular (non-superuser) role when `pg_cron` is preinstalled. See [docker/README.md](../docker/README.md). +If your deployment sits behind a NAT/firewall/proxy that can silently drop idle TCP connections (leaving the pool with dead sockets that hang instead of erroring), add `connect_timeout` and `socket_timeout` (seconds) to `DATABASE_URL` so Prisma gives up on a stale connection instead of hanging indefinitely - e.g. `?connect_timeout=5&socket_timeout=5`. `/api/readyz`'s own query timeout only bounds queries that reach Postgres; it can't help if the socket itself is dead. + ### Authentication (NextAuth) - `NEXTAUTH_SECRET`: Secret used to sign tokens. Generate with `openssl rand -base64 32`. diff --git a/src/pages/api/healthz.ts b/src/pages/api/healthz.ts new file mode 100644 index 000000000..1fc8d040c --- /dev/null +++ b/src/pages/api/healthz.ts @@ -0,0 +1,16 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; + +/** + * Liveness check. Deliberately does not touch the database (or any other external + * dependency) - an orchestrator restarting this process would not fix a database + * outage, and could cause cascading restarts across all replicas at once. See + * `/api/readyz` for a database-aware check suitable for readiness/traffic routing. + */ +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if ('GET' !== req.method) { + res.setHeader('Allow', 'GET'); + return res.status(405).json({ status: 'error', message: 'Method not allowed' }); + } + + return res.status(200).json({ status: 'ok' }); +} diff --git a/src/pages/api/readyz.ts b/src/pages/api/readyz.ts new file mode 100644 index 000000000..37b6d0a53 --- /dev/null +++ b/src/pages/api/readyz.ts @@ -0,0 +1,39 @@ +import { Prisma } from '@prisma/client'; +import type { NextApiRequest, NextApiResponse } from 'next'; + +import { db } from '~/server/db'; + +const DB_CHECK_TIMEOUT_MS = 3000; + +/** + * Readiness check. Verifies the database is reachable, bounded by a short timeout so a + * hung connection fails the check quickly instead of leaving the request pending. The + * timeout is enforced by Postgres itself (`statement_timeout`, scoped to this transaction + * via `SET LOCAL`) rather than raced client-side, so a slow query is actually cancelled + * instead of left running against the connection pool. Meant for load balancers/ + * orchestrators to stop routing traffic here without restarting the process - see + * `/api/healthz` for the dependency-free liveness check. + * + * This only bounds queries that reach Postgres - it can't help if the socket itself is + * dead (see the `connect_timeout`/`socket_timeout` note in docs/CONFIGURATION.md), so it's + * a complement to those connection-string options rather than a replacement for them. + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if ('GET' !== req.method) { + res.setHeader('Allow', 'GET'); + return res.status(405).json({ status: 'error', message: 'Method not allowed' }); + } + + try { + // DB_CHECK_TIMEOUT_MS is a hardcoded constant, not user input, so inlining it via + // Prisma.raw is safe here - `SET` does not accept bound query parameters. + await db.$transaction([ + db.$executeRaw`SET LOCAL statement_timeout = ${Prisma.raw(String(DB_CHECK_TIMEOUT_MS))}`, + db.$queryRaw`SELECT 1`, + ]); + return res.status(200).json({ status: 'ok' }); + } catch (error) { + console.error('Readiness check failed:', error); + return res.status(503).json({ status: 'error', message: 'Database not reachable' }); + } +} diff --git a/src/tests/healthz.test.ts b/src/tests/healthz.test.ts new file mode 100644 index 000000000..3812bed72 --- /dev/null +++ b/src/tests/healthz.test.ts @@ -0,0 +1,37 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; + +import handler from '~/pages/api/healthz'; + +const createMockRes = () => { + const res = {} as NextApiResponse; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + return res; +}; + +describe('/api/healthz', () => { + describe('GET requests', () => { + it('responds with 200 ok for GET requests', () => { + const req = { method: 'GET' } as NextApiRequest; + const res = createMockRes(); + + handler(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ status: 'ok' }); + }); + }); + + describe('method validation', () => { + it('rejects non-GET methods', () => { + const req = { method: 'POST' } as NextApiRequest; + const res = createMockRes(); + + handler(req, res); + + expect(res.status).toHaveBeenCalledWith(405); + expect((res.setHeader as jest.Mock).mock.calls).toContainEqual(['Allow', 'GET']); + }); + }); +}); diff --git a/src/tests/readyz.test.ts b/src/tests/readyz.test.ts new file mode 100644 index 000000000..e6ae32234 --- /dev/null +++ b/src/tests/readyz.test.ts @@ -0,0 +1,118 @@ +import type { Prisma } from '@prisma/client'; +import type { NextApiRequest, NextApiResponse } from 'next'; + +jest.mock('~/server/db', () => ({ + db: { + $executeRaw: jest.fn(), + $queryRaw: jest.fn(), + $transaction: jest.fn(), + }, +})); + +import { db } from '~/server/db'; +import handler from '~/pages/api/readyz'; + +const createMockRes = () => { + const res = {} as NextApiResponse; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + return res; +}; + +describe('/api/readyz', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('successful checks', () => { + it('responds with 200 ok when the database is reachable', async () => { + (db.$transaction as jest.Mock).mockResolvedValue([undefined, [{ '?column?': 1 }]]); + const req = { method: 'GET' } as NextApiRequest; + const res = createMockRes(); + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ status: 'ok' }); + }); + + it('scopes a 3s statement_timeout to the check via SET LOCAL', async () => { + (db.$executeRaw as jest.Mock).mockReturnValue('SET_STATEMENT_TIMEOUT'); + (db.$queryRaw as jest.Mock).mockReturnValue('SELECT_1'); + (db.$transaction as jest.Mock).mockResolvedValue([undefined, [{ '?column?': 1 }]]); + const req = { method: 'GET' } as NextApiRequest; + const res = createMockRes(); + + await handler(req, res); + + const executeRawCall = (db.$executeRaw as jest.Mock).mock.calls[0] as + | [TemplateStringsArray, Prisma.Sql] + | undefined; + if (!executeRawCall) { + throw new Error('Expected db.$executeRaw to have been called'); + } + const [setTimeoutStrings, rawTimeoutValue] = executeRawCall; + expect(setTimeoutStrings.join('')).toContain('SET LOCAL statement_timeout = '); + expect(rawTimeoutValue.strings.join('')).toBe('3000'); + + const queryRawCall = (db.$queryRaw as jest.Mock).mock.calls[0] as + | [TemplateStringsArray] + | undefined; + if (!queryRawCall) { + throw new Error('Expected db.$queryRaw to have been called'); + } + const [selectStrings] = queryRawCall; + expect(selectStrings.join('')).toBe('SELECT 1'); + + // Order matters: the timeout must be set before SELECT 1 runs in the same transaction. + expect((db.$transaction as jest.Mock).mock.calls[0]?.[0]).toEqual([ + 'SET_STATEMENT_TIMEOUT', + 'SELECT_1', + ]); + }); + }); + + describe('database failures', () => { + it('responds with 503 when the database is unreachable', async () => { + (db.$transaction as jest.Mock).mockRejectedValue(new Error('connection refused')); + const req = { method: 'GET' } as NextApiRequest; + const res = createMockRes(); + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(503); + expect(res.json).toHaveBeenCalledWith({ + status: 'error', + message: 'Database not reachable', + }); + }); + + it('responds with 503 when the database check times out', async () => { + (db.$transaction as jest.Mock).mockRejectedValue( + new Error('canceling statement due to statement timeout'), + ); + const req = { method: 'GET' } as NextApiRequest; + const res = createMockRes(); + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(503); + }); + }); + + describe('method validation', () => { + it('rejects non-GET methods', async () => { + const req = { method: 'POST' } as NextApiRequest; + const res = createMockRes(); + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(405); + expect((res.setHeader as jest.Mock).mock.calls).toContainEqual(['Allow', 'GET']); + expect((db.$executeRaw as jest.Mock).mock.calls).toHaveLength(0); + expect((db.$queryRaw as jest.Mock).mock.calls).toHaveLength(0); + expect((db.$transaction as jest.Mock).mock.calls).toHaveLength(0); + }); + }); +});