From 243b6ff55263320f08bd80f4b4380cb6e727de1d Mon Sep 17 00:00:00 2001 From: Sakariyah Abdulhazeem Date: Wed, 2 Sep 2026 14:51:18 +0100 Subject: [PATCH] fix: add keeper status request timeouts for #272 --- packages/sdk/src/status-client.test.ts | 4 +++ packages/sdk/src/status-client.ts | 43 ++++++++++++++++---------- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/packages/sdk/src/status-client.test.ts b/packages/sdk/src/status-client.test.ts index f298e6d..37cc3b9 100644 --- a/packages/sdk/src/status-client.test.ts +++ b/packages/sdk/src/status-client.test.ts @@ -30,6 +30,10 @@ function mockFetch(body: string, status = 200): typeof fetch { } describe("KeeperStatusClient successful JSON parsing", () => { + it("aborts a request after the configured timeout", async () => { + const client = new KeeperStatusClient({ baseURL: "http://keeper.test", timeoutMs: 1, fetchImpl: async (_url, init) => new Promise((_resolve, reject) => init?.signal?.addEventListener("abort", () => reject(init.signal?.reason))) }); + await assert.rejects(() => client.getStatus(), /timed out/i); + }); it("returns valid successful JSON unchanged", async () => { const client = new KeeperStatusClient({ baseURL: "http://keeper.test", diff --git a/packages/sdk/src/status-client.ts b/packages/sdk/src/status-client.ts index cdb3637..0150b1b 100644 --- a/packages/sdk/src/status-client.ts +++ b/packages/sdk/src/status-client.ts @@ -14,6 +14,7 @@ export interface StatusClientOptions { baseURL: string; fetchImpl?: typeof fetch; headers?: Record; + timeoutMs?: number; } export class StatusApiError extends Error { @@ -70,11 +71,13 @@ export class KeeperStatusClient { readonly baseURL: string; readonly fetchImpl: typeof fetch; readonly headers: Record; + readonly timeoutMs: number; constructor(opts: StatusClientOptions) { this.baseURL = opts.baseURL; this.fetchImpl = opts.fetchImpl ?? globalThis.fetch; this.headers = opts.headers ?? {}; + this.timeoutMs = opts.timeoutMs ?? 10_000; if (!this.fetchImpl) { throw new Error( "No global fetch found. Pass `fetchImpl` in StatusClientOptions.", @@ -82,33 +85,39 @@ export class KeeperStatusClient { } } - async getStatus(): Promise { - return this.getJSON("/status"); + async getStatus(signal?: AbortSignal): Promise { + return this.getJSON("/status", signal); } - async getRound(roundId: number | bigint | string): Promise { - return this.getJSON(`/status/rounds/${roundId}`); + async getRound(roundId: number | bigint | string, signal?: AbortSignal): Promise { + return this.getJSON(`/status/rounds/${roundId}`, signal); } - async getHealth(): Promise { - return this.getJSON("/status/health"); + async getHealth(signal?: AbortSignal): Promise { + return this.getJSON("/status/health", signal); } - async healthz(): Promise<{ ok: boolean; [k: string]: unknown }> { - return this.getJSON<{ ok: boolean; [k: string]: unknown }>("/healthz"); + async healthz(signal?: AbortSignal): Promise<{ ok: boolean; [k: string]: unknown }> { + return this.getJSON<{ ok: boolean; [k: string]: unknown }>("/healthz", signal); } - async getJSON(path: string): Promise { + async getJSON(path: string, callerSignal?: AbortSignal): Promise { const url = fullURL(this.baseURL, path); - const res = await this.fetchImpl(url, { - method: "GET", - headers: { Accept: "application/json", ...this.headers }, - }); - if (!res.ok) { - const body = await parseErrorBody(res); - throw new StatusApiError(res.status, body); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(new Error("keeper status request timed out")), this.timeoutMs); + const forwardAbort = () => controller.abort(callerSignal?.reason); + callerSignal?.addEventListener("abort", forwardAbort, { once: true }); + try { + const res = await this.fetchImpl(url, { method: "GET", headers: { Accept: "application/json", ...this.headers }, signal: controller.signal }); + if (!res.ok) { + const body = await parseErrorBody(res); + throw new StatusApiError(res.status, body); + } + return parseSuccessBody(res); + } finally { + clearTimeout(timer); + callerSignal?.removeEventListener("abort", forwardAbort); } - return parseSuccessBody(res); } }