Skip to content

Commit 243b6ff

Browse files
committed
fix: add keeper status request timeouts for #272
1 parent 90d2a52 commit 243b6ff

2 files changed

Lines changed: 30 additions & 17 deletions

File tree

packages/sdk/src/status-client.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ function mockFetch(body: string, status = 200): typeof fetch {
3030
}
3131

3232
describe("KeeperStatusClient successful JSON parsing", () => {
33+
it("aborts a request after the configured timeout", async () => {
34+
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))) });
35+
await assert.rejects(() => client.getStatus(), /timed out/i);
36+
});
3337
it("returns valid successful JSON unchanged", async () => {
3438
const client = new KeeperStatusClient({
3539
baseURL: "http://keeper.test",

packages/sdk/src/status-client.ts

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface StatusClientOptions {
1414
baseURL: string;
1515
fetchImpl?: typeof fetch;
1616
headers?: Record<string, string>;
17+
timeoutMs?: number;
1718
}
1819

1920
export class StatusApiError extends Error {
@@ -70,45 +71,53 @@ export class KeeperStatusClient {
7071
readonly baseURL: string;
7172
readonly fetchImpl: typeof fetch;
7273
readonly headers: Record<string, string>;
74+
readonly timeoutMs: number;
7375

7476
constructor(opts: StatusClientOptions) {
7577
this.baseURL = opts.baseURL;
7678
this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
7779
this.headers = opts.headers ?? {};
80+
this.timeoutMs = opts.timeoutMs ?? 10_000;
7881
if (!this.fetchImpl) {
7982
throw new Error(
8083
"No global fetch found. Pass `fetchImpl` in StatusClientOptions.",
8184
);
8285
}
8386
}
8487

85-
async getStatus(): Promise<KeeperStatusResponse> {
86-
return this.getJSON<KeeperStatusResponse>("/status");
88+
async getStatus(signal?: AbortSignal): Promise<KeeperStatusResponse> {
89+
return this.getJSON<KeeperStatusResponse>("/status", signal);
8790
}
8891

89-
async getRound(roundId: number | bigint | string): Promise<KeeperRoundStatusView> {
90-
return this.getJSON<KeeperRoundStatusView>(`/status/rounds/${roundId}`);
92+
async getRound(roundId: number | bigint | string, signal?: AbortSignal): Promise<KeeperRoundStatusView> {
93+
return this.getJSON<KeeperRoundStatusView>(`/status/rounds/${roundId}`, signal);
9194
}
9295

93-
async getHealth(): Promise<KeeperHealthResponse> {
94-
return this.getJSON<KeeperHealthResponse>("/status/health");
96+
async getHealth(signal?: AbortSignal): Promise<KeeperHealthResponse> {
97+
return this.getJSON<KeeperHealthResponse>("/status/health", signal);
9598
}
9699

97-
async healthz(): Promise<{ ok: boolean; [k: string]: unknown }> {
98-
return this.getJSON<{ ok: boolean; [k: string]: unknown }>("/healthz");
100+
async healthz(signal?: AbortSignal): Promise<{ ok: boolean; [k: string]: unknown }> {
101+
return this.getJSON<{ ok: boolean; [k: string]: unknown }>("/healthz", signal);
99102
}
100103

101-
async getJSON<T>(path: string): Promise<T> {
104+
async getJSON<T>(path: string, callerSignal?: AbortSignal): Promise<T> {
102105
const url = fullURL(this.baseURL, path);
103-
const res = await this.fetchImpl(url, {
104-
method: "GET",
105-
headers: { Accept: "application/json", ...this.headers },
106-
});
107-
if (!res.ok) {
108-
const body = await parseErrorBody(res);
109-
throw new StatusApiError(res.status, body);
106+
const controller = new AbortController();
107+
const timer = setTimeout(() => controller.abort(new Error("keeper status request timed out")), this.timeoutMs);
108+
const forwardAbort = () => controller.abort(callerSignal?.reason);
109+
callerSignal?.addEventListener("abort", forwardAbort, { once: true });
110+
try {
111+
const res = await this.fetchImpl(url, { method: "GET", headers: { Accept: "application/json", ...this.headers }, signal: controller.signal });
112+
if (!res.ok) {
113+
const body = await parseErrorBody(res);
114+
throw new StatusApiError(res.status, body);
115+
}
116+
return parseSuccessBody<T>(res);
117+
} finally {
118+
clearTimeout(timer);
119+
callerSignal?.removeEventListener("abort", forwardAbort);
110120
}
111-
return parseSuccessBody<T>(res);
112121
}
113122
}
114123

0 commit comments

Comments
 (0)