Skip to content

Commit 36b8dad

Browse files
rubenhensendobby-coder[bot]claude
authored
feat: add /readyz readiness probe with database check (#113)
* feat: add /readyz readiness probe with database check * 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 <noreply@anthropic.com> --------- Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 01bbf76 commit 36b8dad

3 files changed

Lines changed: 89 additions & 0 deletions

File tree

src/routes/readyz/+server.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { json } from '@sveltejs/kit';
2+
import { sql } from 'drizzle-orm';
3+
import { db } from '$lib/server/db';
4+
import type { RequestHandler } from './$types';
5+
6+
// Readiness probe: reports 200 only when the app can actually serve traffic
7+
// (i.e. the database is reachable). This is distinct from /health, which is a
8+
// pure liveness check. Orchestrator readiness gates should point here; the
9+
// container liveness HEALTHCHECK stays on /health.
10+
// A readiness verdict must never be cached: the endpoint flips between
11+
// 200/ready and 503/unavailable, and a stale cached response could mis-gate
12+
// traffic. Mark every response no-store so no intermediary reuses it.
13+
const NO_STORE = { 'Cache-Control': 'no-store' };
14+
15+
export const GET: RequestHandler = async () => {
16+
try {
17+
await db.execute(sql`select 1`);
18+
} catch (err) {
19+
console.error('[readyz] database check failed', err);
20+
return json(
21+
{ status: 'unavailable', checks: { database: 'down' } },
22+
{ status: 503, headers: NO_STORE }
23+
);
24+
}
25+
26+
return json({ status: 'ready', checks: { database: 'ok' } }, { headers: NO_STORE });
27+
};

tests/e2e/readyz.e2e.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test('readiness endpoint reports ready when the database is reachable', async ({ request }) => {
4+
const response = await request.get('/readyz');
5+
expect(response.status()).toBe(200);
6+
const body = await response.json();
7+
expect(body.status).toBe('ready');
8+
expect(body.checks.database).toBe('ok');
9+
});

tests/unit/readyz-endpoint.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
3+
const { executeMock } = vi.hoisted(() => ({
4+
executeMock: vi.fn()
5+
}));
6+
7+
vi.mock('$lib/server/db', () => ({ db: { execute: executeMock } }));
8+
9+
vi.mock('drizzle-orm', () => ({
10+
sql: (strings: TemplateStringsArray) => strings.join('')
11+
}));
12+
13+
// SvelteKit's generated $types is not available under vitest; the endpoint only
14+
// uses the type import, so stub it out.
15+
vi.mock('./$types', () => ({}));
16+
17+
import { GET } from '../../src/routes/readyz/+server';
18+
19+
// The route handler only reads request-independent state, so an empty event is fine.
20+
const callGet = () => GET({} as never);
21+
22+
describe('/readyz readiness probe', () => {
23+
beforeEach(() => {
24+
executeMock.mockReset();
25+
});
26+
27+
it('returns 200/ready with no-store when the database is reachable', async () => {
28+
executeMock.mockResolvedValueOnce(undefined);
29+
30+
const response = await callGet();
31+
32+
expect(response.status).toBe(200);
33+
expect(response.headers.get('cache-control')).toBe('no-store');
34+
expect(await response.json()).toEqual({ status: 'ready', checks: { database: 'ok' } });
35+
});
36+
37+
it('returns 503/unavailable with no-store when the database check throws', async () => {
38+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
39+
executeMock.mockRejectedValueOnce(new Error('connection refused'));
40+
41+
const response = await callGet();
42+
43+
expect(response.status).toBe(503);
44+
expect(response.headers.get('cache-control')).toBe('no-store');
45+
expect(await response.json()).toEqual({
46+
status: 'unavailable',
47+
checks: { database: 'down' }
48+
});
49+
expect(consoleError).toHaveBeenCalled();
50+
51+
consoleError.mockRestore();
52+
});
53+
});

0 commit comments

Comments
 (0)