Skip to content

Commit c729980

Browse files
authored
Merge pull request #307 from orochimaru144/fix/issue-272
Add keeper status request timeouts
2 parents 89e43f8 + 243b6ff commit c729980

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
@@ -15,6 +15,7 @@ export interface StatusClientOptions {
1515
baseURL: string;
1616
fetchImpl?: typeof fetch;
1717
headers?: Record<string, string>;
18+
timeoutMs?: number;
1819
}
1920

2021
export class StatusApiError extends Error {
@@ -71,45 +72,53 @@ export class KeeperStatusClient {
7172
readonly baseURL: string;
7273
readonly fetchImpl: typeof fetch;
7374
readonly headers: Record<string, string>;
75+
readonly timeoutMs: number;
7476

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

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

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

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

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

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

0 commit comments

Comments
 (0)