From a387a7a70521aa8314dc7c7e6101e400e6c0d54b Mon Sep 17 00:00:00 2001 From: Jeffrey Graham Date: Tue, 10 Nov 2026 04:34:57 -0500 Subject: [PATCH] feat(frontend): add /api/health endpoint and point compose healthcheck at it --- docker-compose.yml | 2 +- frontend/src/app/api/health/route.test.ts | 109 ++++++++++++++++++++++ frontend/src/app/api/health/route.ts | 103 ++++++++++++++++++++ frontend/src/lib/indexed-db.ts | 5 + 4 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/api/health/route.test.ts create mode 100644 frontend/src/app/api/health/route.ts diff --git a/docker-compose.yml b/docker-compose.yml index abc9853..2754b3b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,7 +46,7 @@ services: ports: - "3000:3000" healthcheck: - test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/').then((r) => process.exit(r.status === 200 ? 0 : 1), () => process.exit(1))"] + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then((r) => process.exit(r.status === 200 ? 0 : 1), () => process.exit(1))"] interval: 10s timeout: 5s retries: 5 diff --git a/frontend/src/app/api/health/route.test.ts b/frontend/src/app/api/health/route.test.ts new file mode 100644 index 0000000..49e8772 --- /dev/null +++ b/frontend/src/app/api/health/route.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockIsIndexerConfigured, mockCheckDatabaseHealth } = vi.hoisted(() => ({ + mockIsIndexerConfigured: vi.fn<() => boolean>(), + mockCheckDatabaseHealth: vi.fn<() => Promise>(), +})); + +vi.mock("@/lib/indexed-db", () => ({ + isIndexerConfigured: mockIsIndexerConfigured, + checkDatabaseHealth: mockCheckDatabaseHealth, +})); + +import { GET } from "./route"; + +describe("GET /api/health", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + vi.clearAllMocks(); + mockIsIndexerConfigured.mockReturnValue(false); + mockCheckDatabaseHealth.mockResolvedValue(undefined); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("answers immediately with 200 on the cheap liveness path without calling Horizon, RPC, or Postgres", async () => { + const fetchSpy = vi.fn().mockRejectedValue(new Error("network down")); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const res = await GET(new Request("http://localhost:3000/api/health")); + expect(res.status).toBe(200); + expect(res.headers.get("Cache-Control")).toBe("no-store"); + await expect(res.json()).resolves.toEqual({ status: "ok" }); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(mockCheckDatabaseHealth).not.toHaveBeenCalled(); + }); + + it("returns 200 on ?deep=1 when Horizon, RPC, and the configured database are healthy", async () => { + mockIsIndexerConfigured.mockReturnValue(true); + globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/ledgers")) { + return new Response(JSON.stringify({ _embedded: { records: [] } }), { status: 200 }); + } + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { status: "healthy" } }), { + status: 200, + }); + }) as unknown as typeof fetch; + + const res = await GET(new Request("http://localhost:3000/api/health?deep=1")); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + status: "ok", + checks: { + horizon: { ok: true }, + rpc: { ok: true }, + database: { ok: true, configured: true }, + }, + }); + }); + + it("returns 503 and reports Horizon when Horizon is unreachable on ?deep=1", async () => { + globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/ledgers")) { + throw new Error("connect ECONNREFUSED"); + } + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { status: "healthy" } }), { + status: 200, + }); + }) as unknown as typeof fetch; + + const res = await GET(new Request("http://localhost:3000/api/health?deep=1")); + expect(res.status).toBe(503); + const body = await res.json(); + expect(body.status).toBe("unhealthy"); + expect(body.checks.horizon).toEqual({ ok: false, error: "connect ECONNREFUSED" }); + expect(body.checks.rpc).toEqual({ ok: true }); + expect(body.checks.database).toEqual({ ok: true, configured: false }); + }); + + it("returns 503 and reports the database when Postgres fails on ?deep=1", async () => { + mockIsIndexerConfigured.mockReturnValue(true); + mockCheckDatabaseHealth.mockRejectedValueOnce(new Error("connection terminated unexpectedly")); + globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/ledgers")) { + return new Response("{}", { status: 200 }); + } + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { status: "healthy" } }), { + status: 200, + }); + }) as unknown as typeof fetch; + + const res = await GET(new Request("http://localhost:3000/api/health?deep=1")); + expect(res.status).toBe(503); + const body = await res.json(); + expect(body.status).toBe("unhealthy"); + expect(body.checks.horizon).toEqual({ ok: true }); + expect(body.checks.rpc).toEqual({ ok: true }); + expect(body.checks.database).toEqual({ + ok: false, + configured: true, + error: "connection terminated unexpectedly", + }); + }); +}); diff --git a/frontend/src/app/api/health/route.ts b/frontend/src/app/api/health/route.ts new file mode 100644 index 0000000..46d9d54 --- /dev/null +++ b/frontend/src/app/api/health/route.ts @@ -0,0 +1,103 @@ +import { NextResponse } from "next/server"; +import { config } from "@/lib/config"; +import { checkDatabaseHealth, isIndexerConfigured } from "@/lib/indexed-db"; + +export const dynamic = "force-dynamic"; + +const CHECK_TIMEOUT_MS = 5_000; + +export interface DependencyStatus { + ok: boolean; + configured?: boolean; + error?: string; +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +async function checkHorizon(): Promise { + try { + const res = await fetch(`${config.horizonUrl}/ledgers?order=desc&limit=1`, { + cache: "no-store", + signal: AbortSignal.timeout(CHECK_TIMEOUT_MS), + }); + if (!res.ok) { + return { ok: false, error: `Horizon returned HTTP ${res.status}` }; + } + return { ok: true }; + } catch (err) { + return { ok: false, error: errorMessage(err) }; + } +} + +async function checkRpc(): Promise { + try { + const res = await fetch(config.sorobanRpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getHealth" }), + cache: "no-store", + signal: AbortSignal.timeout(CHECK_TIMEOUT_MS), + }); + if (!res.ok) { + return { ok: false, error: `Soroban RPC returned HTTP ${res.status}` }; + } + const body = (await res.json()) as { + result?: { status?: string }; + error?: { message?: string }; + }; + if (body.error) { + return { ok: false, error: body.error.message ?? "Soroban RPC error" }; + } + if (body.result?.status && body.result.status !== "healthy") { + return { ok: false, error: `Soroban RPC status: ${body.result.status}` }; + } + return { ok: true }; + } catch (err) { + return { ok: false, error: errorMessage(err) }; + } +} + +async function checkDatabase(): Promise { + if (!isIndexerConfigured()) { + return { ok: true, configured: false }; + } + try { + await checkDatabaseHealth(); + return { ok: true, configured: true }; + } catch (err) { + return { ok: false, configured: true, error: errorMessage(err) }; + } +} + +export async function GET(req: Request): Promise { + const url = new URL(req.url); + const deepParam = url.searchParams.get("deep"); + const isDeep = deepParam === "1" || deepParam === "true"; + + if (!isDeep) { + return NextResponse.json( + { status: "ok" }, + { status: 200, headers: { "Cache-Control": "no-store" } } + ); + } + + const [horizon, rpc, database] = await Promise.all([ + checkHorizon(), + checkRpc(), + checkDatabase(), + ]); + + const healthy = horizon.ok && rpc.ok && database.ok; + return NextResponse.json( + { + status: healthy ? "ok" : "unhealthy", + checks: { horizon, rpc, database }, + }, + { + status: healthy ? 200 : 503, + headers: { "Cache-Control": "no-store" }, + } + ); +} diff --git a/frontend/src/lib/indexed-db.ts b/frontend/src/lib/indexed-db.ts index b16224c..b055377 100644 --- a/frontend/src/lib/indexed-db.ts +++ b/frontend/src/lib/indexed-db.ts @@ -56,6 +56,11 @@ function getPool(): Pool { return pool; } +/** Verify the configured Postgres pool can answer a trivial query. */ +export async function checkDatabaseHealth(): Promise { + await getPool().query("SELECT 1"); +} + export interface IndexedInvocation { txHash: string; ledger: number;