Skip to content

Commit a0eb815

Browse files
committed
feat(ops): add /readyz readiness probe for Postgres and Redis
Public readiness endpoint for HAProxy blue/green cutover. /healthz stays liveness-only; /readyz returns 503 when Postgres or Redis is unavailable.
1 parent a0853db commit a0eb815

6 files changed

Lines changed: 192 additions & 0 deletions

File tree

‎.changeset/readyz-endpoint.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"nostream": patch
3+
---
4+
5+
feat(ops): add /readyz readiness probe for Postgres and Redis
6+
7+
Adds a public readiness endpoint for zero-downtime deploy workflows. HAProxy (or similar) can use `/readyz` to confirm an instance can serve traffic before cutover, while `/healthz` remains a lightweight liveness check.

‎deploy/README.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,23 @@ Or use the admin API/UI once `admin.enabled` is configured.
9898
```bash
9999
docker compose ps
100100
curl -s -H 'Accept: application/nostr+json' http://127.0.0.1:8008/
101+
curl -s http://127.0.0.1:8008/readyz
101102
```
102103

104+
## Health checks
105+
106+
Use the relay HTTP port (default `8008`) for deploy and load-balancer probes:
107+
108+
| Endpoint | Type | Behavior | Typical use |
109+
|------------|------------|----------------------------------------------------------|-------------------------------|
110+
| `/healthz` | Liveness | Always `200 OK` if the process is running | Restart unhealthy containers |
111+
| `/readyz` | Readiness | `200` when Postgres and Redis respond; `503` otherwise | HAProxy blue/green cutover |
112+
113+
`/readyz` is unauthenticated and intended for infrastructure. It reuses the same
114+
Postgres and Redis checks as `/admin/health` without requiring admin auth.
115+
Use readiness before routing traffic to a new instance during deploys; graceful
116+
WebSocket draining on shutdown is planned as a follow-up.
117+
103118
## Image delivery on restricted networks
104119

105120
Some hosts cannot reach GHCR over IPv4:
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { NextFunction, Request, Response } from 'express'
2+
3+
import { AdminDependencyHealth, collectAdminHealthSnapshot } from '../../utils/admin-health'
4+
5+
// Public readiness probe for load balancers (e.g. HAProxy blue/green). Unlike /healthz
6+
// (liveness), /readyz returns non-200 when Postgres or Redis is unavailable.
7+
export interface ReadyzSnapshot {
8+
status: 'ok' | 'unavailable'
9+
database: AdminDependencyHealth
10+
redis: AdminDependencyHealth
11+
}
12+
13+
export const buildReadyzSnapshot = (database: AdminDependencyHealth, redis: AdminDependencyHealth): ReadyzSnapshot => {
14+
const ready = database.ok && redis.ok
15+
16+
return {
17+
status: ready ? 'ok' : 'unavailable',
18+
database,
19+
redis,
20+
}
21+
}
22+
23+
export const getReadyzRequestHandler = async (_req: Request, res: Response, next: NextFunction) => {
24+
try {
25+
const health = await collectAdminHealthSnapshot()
26+
const snapshot = buildReadyzSnapshot(health.database, health.redis)
27+
const statusCode = snapshot.status === 'ok' ? 200 : 503
28+
29+
res.status(statusCode).setHeader('content-type', 'application/json; charset=utf-8').send(snapshot)
30+
} catch {
31+
res
32+
.status(503)
33+
.setHeader('content-type', 'application/json; charset=utf-8')
34+
.send({
35+
status: 'unavailable',
36+
database: { ok: false },
37+
redis: { ok: false },
38+
} satisfies ReadyzSnapshot)
39+
}
40+
41+
next()
42+
}

‎src/routes/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import adminRouter from './admin'
55
import admissionRouter from './admissions'
66
import callbacksRouter from './callbacks'
77
import { getHealthRequestHandler } from '../handlers/request-handlers/get-health-request-handler'
8+
import { getReadyzRequestHandler } from '../handlers/request-handlers/get-readyz-request-handler'
89
import { getPrivacyRequestHandler } from '../handlers/request-handlers/get-privacy-request-handler'
910
import { getTermsRequestHandler } from '../handlers/request-handlers/get-terms-request-handler'
1011
import invoiceRouter from './invoices'
@@ -24,7 +25,10 @@ router.use((req, res, next) => {
2425

2526
// codeql[js/missing-rate-limiting]
2627
router.get('/', rootRequestHandler)
28+
// Liveness: process is running (always 200). Used for "is the container up?" checks.
2729
router.get('/healthz', getHealthRequestHandler)
30+
// Readiness: Postgres + Redis must respond. Used before routing traffic during deploys.
31+
router.get('/readyz', getReadyzRequestHandler)
2832
router.get('/terms', getTermsRequestHandler)
2933
router.get('/privacy', getPrivacyRequestHandler)
3034

‎test/integration/features/response-types/response-types.feature‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Feature: HTTP response types
1010
| / | application/nostr+json | 200 | application/nostr+json |
1111
| / | text/html | 200 | text/html |
1212
| /healthz | */* | 200 | text/plain |
13+
| /readyz | */* | 200 | application/json |
1314
| /terms | */* | 200 | text/html |
1415
| /.well-known/nodeinfo | */* | 200 | application/json |
1516
| /nodeinfo/2.1 | */* | 200 | application/json |
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import chai from 'chai'
2+
import sinon from 'sinon'
3+
import sinonChai from 'sinon-chai'
4+
5+
import * as adminHealth from '../../../../src/utils/admin-health'
6+
import {
7+
buildReadyzSnapshot,
8+
getReadyzRequestHandler,
9+
} from '../../../../src/handlers/request-handlers/get-readyz-request-handler'
10+
11+
chai.use(sinonChai)
12+
const { expect } = chai
13+
14+
describe('buildReadyzSnapshot', () => {
15+
it('returns ok when database and redis are healthy', () => {
16+
expect(buildReadyzSnapshot({ ok: true }, { ok: true })).to.deep.equal({
17+
status: 'ok',
18+
database: { ok: true },
19+
redis: { ok: true },
20+
})
21+
})
22+
23+
it('returns unavailable when either dependency is unhealthy', () => {
24+
expect(buildReadyzSnapshot({ ok: false }, { ok: true })).to.deep.equal({
25+
status: 'unavailable',
26+
database: { ok: false },
27+
redis: { ok: true },
28+
})
29+
})
30+
})
31+
32+
describe('getReadyzRequestHandler', () => {
33+
let sandbox: sinon.SinonSandbox
34+
let collectAdminHealthSnapshotStub: sinon.SinonStub
35+
36+
beforeEach(() => {
37+
sandbox = sinon.createSandbox()
38+
collectAdminHealthSnapshotStub = sandbox.stub(adminHealth, 'collectAdminHealthSnapshot')
39+
})
40+
41+
afterEach(() => {
42+
sandbox.restore()
43+
})
44+
45+
it('responds with 200 JSON when dependencies are ready', async () => {
46+
collectAdminHealthSnapshotStub.resolves({
47+
status: 'ok',
48+
uptimeSeconds: 42,
49+
worker: { type: 'primary' },
50+
database: { ok: true },
51+
redis: { ok: true },
52+
})
53+
54+
const req = {} as any
55+
const res = {
56+
status: sinon.stub().returnsThis(),
57+
setHeader: sinon.stub().returnsThis(),
58+
send: sinon.stub().returnsThis(),
59+
} as any
60+
const next = sinon.stub()
61+
62+
await getReadyzRequestHandler(req, res, next)
63+
64+
expect(res.status).to.have.been.calledOnceWithExactly(200)
65+
expect(res.setHeader).to.have.been.calledOnceWithExactly('content-type', 'application/json; charset=utf-8')
66+
expect(res.send).to.have.been.calledOnceWithExactly({
67+
status: 'ok',
68+
database: { ok: true },
69+
redis: { ok: true },
70+
})
71+
expect(next).to.have.been.calledOnce
72+
})
73+
74+
it('responds with 503 JSON when a dependency is unavailable', async () => {
75+
collectAdminHealthSnapshotStub.resolves({
76+
status: 'degraded',
77+
uptimeSeconds: 42,
78+
worker: { type: 'primary' },
79+
database: { ok: true },
80+
redis: { ok: false },
81+
})
82+
83+
const req = {} as any
84+
const res = {
85+
status: sinon.stub().returnsThis(),
86+
setHeader: sinon.stub().returnsThis(),
87+
send: sinon.stub().returnsThis(),
88+
} as any
89+
const next = sinon.stub()
90+
91+
await getReadyzRequestHandler(req, res, next)
92+
93+
expect(res.status).to.have.been.calledOnceWithExactly(503)
94+
expect(res.send).to.have.been.calledOnceWithExactly({
95+
status: 'unavailable',
96+
database: { ok: true },
97+
redis: { ok: false },
98+
})
99+
expect(next).to.have.been.calledOnce
100+
})
101+
102+
it('responds with 503 JSON when dependency collection throws', async () => {
103+
collectAdminHealthSnapshotStub.rejects(new Error('boom'))
104+
105+
const req = {} as any
106+
const res = {
107+
status: sinon.stub().returnsThis(),
108+
setHeader: sinon.stub().returnsThis(),
109+
send: sinon.stub().returnsThis(),
110+
} as any
111+
const next = sinon.stub()
112+
113+
await getReadyzRequestHandler(req, res, next)
114+
115+
expect(res.status).to.have.been.calledOnceWithExactly(503)
116+
expect(res.send).to.have.been.calledOnceWithExactly({
117+
status: 'unavailable',
118+
database: { ok: false },
119+
redis: { ok: false },
120+
})
121+
expect(next).to.have.been.calledOnce
122+
})
123+
})

0 commit comments

Comments
 (0)