diff --git a/packages/web/src/lib/config.ts b/packages/web/src/lib/config.ts index 5ce26ab..f7e8d8f 100644 --- a/packages/web/src/lib/config.ts +++ b/packages/web/src/lib/config.ts @@ -7,6 +7,13 @@ const STORE_KEY = "openconcho:instances"; export const HONCHO_CLOUD_URL = "https://api.honcho.dev"; +/** + * Connection-test timeout. Generous because a cold/idle self-hosted Honcho (DB + * pool spin-up, tunnel wake) can take several seconds on its first request — a + * tight 5s budget reported live-and-reachable instances as "Connection timed out". + */ +export const CONNECTION_TIMEOUT_MS = 15_000; + function normalizeBaseUrl(url: string): string { return url.trim().replace(/\/+$/, "").toLowerCase(); } @@ -162,6 +169,7 @@ export type HealthStatus = "ok" | "auth-required" | "unreachable" | "checking"; export async function checkConnection( baseUrl: string, token?: string, + timeoutMs: number = CONNECTION_TIMEOUT_MS, ): Promise<{ status: HealthStatus; message: string }> { try { const { baseUrl: base, headers, fetch } = dispatchFor({ baseUrl, token }); @@ -169,7 +177,7 @@ export async function checkConnection( method: "POST", headers, body: JSON.stringify({}), - signal: AbortSignal.timeout(5000), + signal: AbortSignal.timeout(timeoutMs), }); const reject = res.headers.get(PROXY_REJECT_HEADER); diff --git a/packages/web/src/test/check-connection.test.ts b/packages/web/src/test/check-connection.test.ts index 1e0b8eb..f12f688 100644 --- a/packages/web/src/test/check-connection.test.ts +++ b/packages/web/src/test/check-connection.test.ts @@ -45,3 +45,30 @@ describe("checkConnection — web proxy mode", () => { expect(res.message).toMatch(/allowlist/i); }); }); + +describe("checkConnection — timeout budget", () => { + // A fetch that resolves after `ms`, but rejects early if the abort signal fires — + // mirrors how a real slow upstream interacts with AbortSignal.timeout. + function delayedFetch(ms: number) { + return (_url: string, init: { signal?: AbortSignal }) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve(new Response("{}", { status: 200 })), ms); + init.signal?.addEventListener("abort", () => { + clearTimeout(timer); + reject(new DOMException("The operation timed out", "TimeoutError")); + }); + }); + } + + it("reports unreachable when the upstream is slower than the timeout budget", async () => { + httpFetchMock.mockImplementation(delayedFetch(80)); + const res = await checkConnection("https://slow.example.net", undefined, 20); + expect(res.status).toBe("unreachable"); + }); + + it("succeeds when a slow upstream responds within the (cold-start) budget", async () => { + httpFetchMock.mockImplementation(delayedFetch(20)); + const res = await checkConnection("https://slow.example.net", undefined, 200); + expect(res.status).toBe("ok"); + }); +});