Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
109 changes: 109 additions & 0 deletions frontend/src/app/api/health/route.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>>(),
}));

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",
});
});
});
103 changes: 103 additions & 0 deletions frontend/src/app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -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<DependencyStatus> {
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<DependencyStatus> {
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<DependencyStatus> {
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<NextResponse> {
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" },
}
);
}
5 changes: 5 additions & 0 deletions frontend/src/lib/indexed-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ function getPool(): Pool {
return pool;
}

/** Verify the configured Postgres pool can answer a trivial query. */
export async function checkDatabaseHealth(): Promise<void> {
await getPool().query("SELECT 1");
}

export interface IndexedInvocation {
txHash: string;
ledger: number;
Expand Down