Skip to content

Commit 9806a5e

Browse files
Merge branch 'main' into feat/wot-graph-service
2 parents ca2da03 + 358763a commit 9806a5e

6 files changed

Lines changed: 274 additions & 1 deletion

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": minor
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: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,27 @@ 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+
Each dependency ping uses the default 3s timeout (`ADMIN_DEPENDENCY_PING_TIMEOUT_MS`).
116+
Set your load balancer check timeout above that (for example HAProxy
117+
`timeout check 5s`) so slow-but-healthy backends do not flap during probes.
118+
Responses are cached in-process for 1s to absorb polling without hammering the DB pool.
119+
Use readiness before routing traffic to a new instance during deploys; graceful
120+
WebSocket draining on shutdown is planned as a follow-up.
121+
103122
## Image delivery on restricted networks
104123

105124
Some hosts cannot reach GHCR over IPv4:
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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+
const READY_SNAPSHOT_CACHE_TTL_MS = 1000
8+
9+
export interface ReadyzSnapshot {
10+
status: 'ok' | 'unavailable'
11+
database: AdminDependencyHealth
12+
redis: AdminDependencyHealth
13+
}
14+
15+
interface CachedReadyzSnapshot {
16+
snapshot: ReadyzSnapshot
17+
expiresAt: number
18+
}
19+
20+
let cachedReadyzSnapshot: CachedReadyzSnapshot | undefined
21+
let inFlightReadyzSnapshot: Promise<ReadyzSnapshot> | undefined
22+
23+
export const resetReadyzSnapshotCache = (): void => {
24+
cachedReadyzSnapshot = undefined
25+
inFlightReadyzSnapshot = undefined
26+
}
27+
28+
export const buildReadyzSnapshot = (database: AdminDependencyHealth, redis: AdminDependencyHealth): ReadyzSnapshot => {
29+
const ready = database.ok && redis.ok
30+
31+
return {
32+
status: ready ? 'ok' : 'unavailable',
33+
database,
34+
redis,
35+
}
36+
}
37+
38+
const collectReadyzSnapshot = async (): Promise<ReadyzSnapshot> => {
39+
const now = Date.now()
40+
if (cachedReadyzSnapshot && cachedReadyzSnapshot.expiresAt > now) {
41+
return cachedReadyzSnapshot.snapshot
42+
}
43+
44+
if (inFlightReadyzSnapshot) {
45+
return inFlightReadyzSnapshot
46+
}
47+
48+
inFlightReadyzSnapshot = (async () => {
49+
const health = await collectAdminHealthSnapshot()
50+
const snapshot = buildReadyzSnapshot(health.database, health.redis)
51+
cachedReadyzSnapshot = {
52+
snapshot,
53+
expiresAt: Date.now() + READY_SNAPSHOT_CACHE_TTL_MS,
54+
}
55+
56+
return snapshot
57+
})()
58+
59+
try {
60+
return await inFlightReadyzSnapshot
61+
} finally {
62+
inFlightReadyzSnapshot = undefined
63+
}
64+
}
65+
66+
const sendReadyzResponse = (res: Response, statusCode: number, snapshot: ReadyzSnapshot): void => {
67+
res
68+
.status(statusCode)
69+
.setHeader('content-type', 'application/json; charset=utf-8')
70+
.setHeader('cache-control', 'no-store')
71+
.send(snapshot)
72+
}
73+
74+
export const getReadyzRequestHandler = async (_req: Request, res: Response, next: NextFunction) => {
75+
try {
76+
const snapshot = await collectReadyzSnapshot()
77+
const statusCode = snapshot.status === 'ok' ? 200 : 503
78+
sendReadyzResponse(res, statusCode, snapshot)
79+
} catch {
80+
sendReadyzResponse(res, 503, {
81+
status: 'unavailable',
82+
database: { ok: false },
83+
redis: { ok: false },
84+
})
85+
}
86+
87+
next()
88+
}

‎src/routes/index.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import admissionRouter from './admissions'
66
import callbacksRouter from './callbacks'
77
import { getHealthRequestHandler } from '../handlers/request-handlers/get-health-request-handler'
88
import { getPrivacyRequestHandler } from '../handlers/request-handlers/get-privacy-request-handler'
9+
import { getReadyzRequestHandler } from '../handlers/request-handlers/get-readyz-request-handler'
910
import { getTermsRequestHandler } from '../handlers/request-handlers/get-terms-request-handler'
1011
import invoiceRouter from './invoices'
1112
import { rateLimiterMiddleware } from '../handlers/request-handlers/rate-limiter-middleware'
@@ -24,9 +25,13 @@ 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)
28-
router.get('/terms', getTermsRequestHandler)
2930
router.get('/privacy', getPrivacyRequestHandler)
31+
// Readiness: Postgres + Redis must respond. Used before routing traffic during deploys.
32+
// codeql[js/missing-rate-limiting]
33+
router.get('/readyz', getReadyzRequestHandler)
34+
router.get('/terms', getTermsRequestHandler)
3035

3136
router.get('/.well-known/nodeinfo', nodeinfoHandler)
3237
router.get('/nodeinfo/2.1', nodeinfo21Handler)

‎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: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
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+
resetReadyzSnapshotCache,
10+
} from '../../../../src/handlers/request-handlers/get-readyz-request-handler'
11+
12+
chai.use(sinonChai)
13+
const { expect } = chai
14+
15+
describe('buildReadyzSnapshot', () => {
16+
it('returns ok when database and redis are healthy', () => {
17+
expect(buildReadyzSnapshot({ ok: true }, { ok: true })).to.deep.equal({
18+
status: 'ok',
19+
database: { ok: true },
20+
redis: { ok: true },
21+
})
22+
})
23+
24+
it('returns unavailable when either dependency is unhealthy', () => {
25+
expect(buildReadyzSnapshot({ ok: false }, { ok: true })).to.deep.equal({
26+
status: 'unavailable',
27+
database: { ok: false },
28+
redis: { ok: true },
29+
})
30+
})
31+
})
32+
33+
describe('getReadyzRequestHandler', () => {
34+
let sandbox: sinon.SinonSandbox
35+
let collectAdminHealthSnapshotStub: sinon.SinonStub
36+
37+
const healthyAdminSnapshot = {
38+
status: 'ok' as const,
39+
uptimeSeconds: 42,
40+
worker: { type: 'primary' },
41+
database: { ok: true },
42+
redis: { ok: true },
43+
}
44+
45+
const createResponse = () =>
46+
({
47+
status: sinon.stub().returnsThis(),
48+
setHeader: sinon.stub().returnsThis(),
49+
send: sinon.stub().returnsThis(),
50+
}) as any
51+
52+
beforeEach(() => {
53+
sandbox = sinon.createSandbox()
54+
collectAdminHealthSnapshotStub = sandbox.stub(adminHealth, 'collectAdminHealthSnapshot')
55+
resetReadyzSnapshotCache()
56+
})
57+
58+
afterEach(() => {
59+
sandbox.restore()
60+
resetReadyzSnapshotCache()
61+
})
62+
63+
it('responds with 200 JSON when dependencies are ready', async () => {
64+
collectAdminHealthSnapshotStub.resolves(healthyAdminSnapshot)
65+
66+
const res = createResponse()
67+
const next = sinon.stub()
68+
69+
await getReadyzRequestHandler({} as any, res, next)
70+
71+
expect(res.status).to.have.been.calledOnceWithExactly(200)
72+
expect(res.setHeader).to.have.been.calledWith('content-type', 'application/json; charset=utf-8')
73+
expect(res.setHeader).to.have.been.calledWith('cache-control', 'no-store')
74+
expect(res.send).to.have.been.calledOnceWithExactly({
75+
status: 'ok',
76+
database: { ok: true },
77+
redis: { ok: true },
78+
})
79+
expect(next).to.have.been.calledOnce
80+
})
81+
82+
it('responds with 503 JSON when redis is unavailable', async () => {
83+
collectAdminHealthSnapshotStub.resolves({
84+
...healthyAdminSnapshot,
85+
status: 'degraded',
86+
redis: { ok: false },
87+
})
88+
89+
const res = createResponse()
90+
const next = sinon.stub()
91+
92+
await getReadyzRequestHandler({} as any, res, next)
93+
94+
expect(res.status).to.have.been.calledOnceWithExactly(503)
95+
expect(res.send).to.have.been.calledOnceWithExactly({
96+
status: 'unavailable',
97+
database: { ok: true },
98+
redis: { ok: false },
99+
})
100+
expect(next).to.have.been.calledOnce
101+
})
102+
103+
it('responds with 503 JSON when database is unavailable', async () => {
104+
collectAdminHealthSnapshotStub.resolves({
105+
...healthyAdminSnapshot,
106+
status: 'degraded',
107+
database: { ok: false },
108+
})
109+
110+
const res = createResponse()
111+
const next = sinon.stub()
112+
113+
await getReadyzRequestHandler({} as any, 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: true },
120+
})
121+
expect(next).to.have.been.calledOnce
122+
})
123+
124+
it('responds with 503 JSON when dependency collection throws', async () => {
125+
collectAdminHealthSnapshotStub.rejects(new Error('boom'))
126+
127+
const res = createResponse()
128+
const next = sinon.stub()
129+
130+
await getReadyzRequestHandler({} as any, res, next)
131+
132+
expect(res.status).to.have.been.calledOnceWithExactly(503)
133+
expect(res.setHeader).to.have.been.calledWith('cache-control', 'no-store')
134+
expect(res.send).to.have.been.calledOnceWithExactly({
135+
status: 'unavailable',
136+
database: { ok: false },
137+
redis: { ok: false },
138+
})
139+
expect(next).to.have.been.calledOnce
140+
})
141+
142+
it('reuses a cached snapshot within the 1s TTL', async () => {
143+
collectAdminHealthSnapshotStub.resolves(healthyAdminSnapshot)
144+
145+
const res = createResponse()
146+
const next = sinon.stub()
147+
148+
await getReadyzRequestHandler({} as any, res, next)
149+
await getReadyzRequestHandler({} as any, res, next)
150+
151+
expect(collectAdminHealthSnapshotStub).to.have.been.calledOnce
152+
})
153+
})

0 commit comments

Comments
 (0)