From bc957f8da23cd7ffaba59a0b863e312c2aac9e90 Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Wed, 1 Jul 2026 13:20:42 +0200 Subject: [PATCH 1/2] feat: add /readyz readiness probe with database check --- src/routes/readyz/+server.ts | 19 +++++++++++++++++++ tests/e2e/readyz.e2e.ts | 9 +++++++++ 2 files changed, 28 insertions(+) create mode 100644 src/routes/readyz/+server.ts create mode 100644 tests/e2e/readyz.e2e.ts diff --git a/src/routes/readyz/+server.ts b/src/routes/readyz/+server.ts new file mode 100644 index 0000000..ff4ea82 --- /dev/null +++ b/src/routes/readyz/+server.ts @@ -0,0 +1,19 @@ +import { json } from '@sveltejs/kit'; +import { sql } from 'drizzle-orm'; +import { db } from '$lib/server/db'; +import type { RequestHandler } from './$types'; + +// Readiness probe: reports 200 only when the app can actually serve traffic +// (i.e. the database is reachable). This is distinct from /health, which is a +// pure liveness check. Orchestrator readiness gates should point here; the +// container liveness HEALTHCHECK stays on /health. +export const GET: RequestHandler = async () => { + try { + await db.execute(sql`select 1`); + } catch (err) { + console.error('[readyz] database check failed', err); + return json({ status: 'unavailable', checks: { database: 'down' } }, { status: 503 }); + } + + return json({ status: 'ready', checks: { database: 'ok' } }); +}; diff --git a/tests/e2e/readyz.e2e.ts b/tests/e2e/readyz.e2e.ts new file mode 100644 index 0000000..0432a88 --- /dev/null +++ b/tests/e2e/readyz.e2e.ts @@ -0,0 +1,9 @@ +import { test, expect } from '@playwright/test'; + +test('readiness endpoint reports ready when the database is reachable', async ({ request }) => { + const response = await request.get('/readyz'); + expect(response.status()).toBe(200); + const body = await response.json(); + expect(body.status).toBe('ready'); + expect(body.checks.database).toBe('ok'); +}); From d7d34214a4ccd105e1dd873a67b77c51fef66612 Mon Sep 17 00:00:00 2001 From: "dobby-yivi-agent[bot]" <275734547+dobby-yivi-agent[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:14:03 +0000 Subject: [PATCH 2/2] fix: set no-store on /readyz responses and add 503-path unit test Addresses review nits on #113: - Mark both the 200/ready and 503/unavailable responses Cache-Control: no-store so an intermediary cache can't serve a stale readiness verdict. - Add tests/unit/readyz-endpoint.test.ts covering both branches by stubbing db.execute, locking in the 503/down shape that the e2e test can't reach. Co-Authored-By: Claude Opus 4.8 --- src/routes/readyz/+server.ts | 12 +++++-- tests/unit/readyz-endpoint.test.ts | 53 ++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 tests/unit/readyz-endpoint.test.ts diff --git a/src/routes/readyz/+server.ts b/src/routes/readyz/+server.ts index ff4ea82..c4a3fcd 100644 --- a/src/routes/readyz/+server.ts +++ b/src/routes/readyz/+server.ts @@ -7,13 +7,21 @@ import type { RequestHandler } from './$types'; // (i.e. the database is reachable). This is distinct from /health, which is a // pure liveness check. Orchestrator readiness gates should point here; the // container liveness HEALTHCHECK stays on /health. +// A readiness verdict must never be cached: the endpoint flips between +// 200/ready and 503/unavailable, and a stale cached response could mis-gate +// traffic. Mark every response no-store so no intermediary reuses it. +const NO_STORE = { 'Cache-Control': 'no-store' }; + export const GET: RequestHandler = async () => { try { await db.execute(sql`select 1`); } catch (err) { console.error('[readyz] database check failed', err); - return json({ status: 'unavailable', checks: { database: 'down' } }, { status: 503 }); + return json( + { status: 'unavailable', checks: { database: 'down' } }, + { status: 503, headers: NO_STORE } + ); } - return json({ status: 'ready', checks: { database: 'ok' } }); + return json({ status: 'ready', checks: { database: 'ok' } }, { headers: NO_STORE }); }; diff --git a/tests/unit/readyz-endpoint.test.ts b/tests/unit/readyz-endpoint.test.ts new file mode 100644 index 0000000..1501e07 --- /dev/null +++ b/tests/unit/readyz-endpoint.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { executeMock } = vi.hoisted(() => ({ + executeMock: vi.fn() +})); + +vi.mock('$lib/server/db', () => ({ db: { execute: executeMock } })); + +vi.mock('drizzle-orm', () => ({ + sql: (strings: TemplateStringsArray) => strings.join('') +})); + +// SvelteKit's generated $types is not available under vitest; the endpoint only +// uses the type import, so stub it out. +vi.mock('./$types', () => ({})); + +import { GET } from '../../src/routes/readyz/+server'; + +// The route handler only reads request-independent state, so an empty event is fine. +const callGet = () => GET({} as never); + +describe('/readyz readiness probe', () => { + beforeEach(() => { + executeMock.mockReset(); + }); + + it('returns 200/ready with no-store when the database is reachable', async () => { + executeMock.mockResolvedValueOnce(undefined); + + const response = await callGet(); + + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(await response.json()).toEqual({ status: 'ready', checks: { database: 'ok' } }); + }); + + it('returns 503/unavailable with no-store when the database check throws', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + executeMock.mockRejectedValueOnce(new Error('connection refused')); + + const response = await callGet(); + + expect(response.status).toBe(503); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(await response.json()).toEqual({ + status: 'unavailable', + checks: { database: 'down' } + }); + expect(consoleError).toHaveBeenCalled(); + + consoleError.mockRestore(); + }); +});