fix(web): raise connection-test timeout for cold upstreams

A cold/idle self-hosted Honcho can take ~5s on its first request (DB pool, tunnel
wake); the hardcoded 5s budget aborted just before the response and reported a
live instance as 'Connection timed out'. Extract CONNECTION_TIMEOUT_MS (15s),
make checkConnection's timeout injectable, and cover the budget behavior.
This commit is contained in:
Offending Commit
2026-06-02 14:18:19 -05:00
parent 4ccf8f2746
commit 409d7d8be7
2 changed files with 36 additions and 1 deletions

View File

@@ -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);

View File

@@ -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<Response>((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");
});
});