Skip to content

Commit d7d3421

Browse files
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>
1 parent bc957f8 commit d7d3421

2 files changed

Lines changed: 63 additions & 2 deletions

File tree

src/routes/readyz/+server.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,21 @@ import type { RequestHandler } from './$types';
77
// (i.e. the database is reachable). This is distinct from /health, which is a
88
// pure liveness check. Orchestrator readiness gates should point here; the
99
// 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+
1015
export const GET: RequestHandler = async () => {
1116
try {
1217
await db.execute(sql`select 1`);
1318
} catch (err) {
1419
console.error('[readyz] database check failed', err);
15-
return json({ status: 'unavailable', checks: { database: 'down' } }, { status: 503 });
20+
return json(
21+
{ status: 'unavailable', checks: { database: 'down' } },
22+
{ status: 503, headers: NO_STORE }
23+
);
1624
}
1725

18-
return json({ status: 'ready', checks: { database: 'ok' } });
26+
return json({ status: 'ready', checks: { database: 'ok' } }, { headers: NO_STORE });
1927
};

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)