Skip to content

Commit ff8fd3f

Browse files
fix(sdk): reject malformed JSON from successful status responses (#242)
Throw StatusJsonParseError for empty or invalid JSON on 2xx keeper status responses while preserving existing StatusApiError handling for non-2xx bodies. Add focused status-client tests and export the new error type. Closes #242 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 32cf0bf commit ff8fd3f

5 files changed

Lines changed: 137 additions & 7 deletions

File tree

packages/sdk/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
".": "./src/index.ts"
2525
},
2626
"scripts": {
27-
"test": "node --import tsx --test src/asset-config.test.ts src/client.test.ts src/encoding.test.ts src/encrypted-blob.test.ts src/errors.test.ts src/export-receipt.test.ts src/ids.test.ts src/mainnet-readiness.test.ts src/network.test.ts src/preflight.test.ts src/public-api-snapshot.test.ts src/receipt.test.ts src/redact.test.ts src/verify.test.ts",
27+
"test": "node --import tsx --test src/asset-config.test.ts src/client.test.ts src/encoding.test.ts src/encrypted-blob.test.ts src/errors.test.ts src/export-receipt.test.ts src/ids.test.ts src/mainnet-readiness.test.ts src/network.test.ts src/preflight.test.ts src/public-api-snapshot.test.ts src/receipt.test.ts src/redact.test.ts src/status-client.test.ts src/verify.test.ts",
2828
"typecheck": "tsc --noEmit -p tsconfig.json"
2929
},
3030
"dependencies": {

packages/sdk/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ export {
126126
export {
127127
KeeperStatusClient,
128128
StatusApiError,
129+
StatusJsonParseError,
129130
type StatusClientOptions,
130131
fetchKeeperStatus,
131132
} from "./status-client.js";

packages/sdk/src/public-api-snapshot.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const EXPECTED_EXPORTS = [
1717
"RoundContract",
1818
"RoundErrors",
1919
"StatusApiError",
20+
"StatusJsonParseError",
2021
"SubRosaClient",
2122
"SubRosaClientConfigError",
2223
"SubRosaMissingReturnValueError",
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { describe, it } from "node:test";
2+
import assert from "node:assert/strict";
3+
4+
import {
5+
KeeperStatusClient,
6+
StatusApiError,
7+
StatusJsonParseError,
8+
} from "./status-client.js";
9+
import type { KeeperStatusResponse } from "./status.js";
10+
11+
const SAMPLE_STATUS: KeeperStatusResponse = {
12+
contractId: "C123",
13+
network: "testnet",
14+
uptimeSeconds: 42,
15+
rounds: [],
16+
health: {
17+
rpc: "ok",
18+
drand: "ok",
19+
checkedAt: "2026-01-01T00:00:00.000Z",
20+
},
21+
now: "2026-01-01T00:00:00.000Z",
22+
};
23+
24+
function mockFetch(body: string, status = 200): typeof fetch {
25+
return async () =>
26+
new Response(body, {
27+
status,
28+
headers: { "content-type": "application/json" },
29+
});
30+
}
31+
32+
describe("KeeperStatusClient successful JSON parsing", () => {
33+
it("returns valid successful JSON unchanged", async () => {
34+
const client = new KeeperStatusClient({
35+
baseURL: "http://keeper.test",
36+
fetchImpl: mockFetch(JSON.stringify(SAMPLE_STATUS)),
37+
});
38+
const status = await client.getStatus();
39+
assert.deepEqual(status, SAMPLE_STATUS);
40+
});
41+
42+
it("rejects empty successful JSON bodies", async () => {
43+
const client = new KeeperStatusClient({
44+
baseURL: "http://keeper.test",
45+
fetchImpl: mockFetch(" "),
46+
});
47+
await assert.rejects(
48+
() => client.getStatus(),
49+
(error: unknown) => {
50+
assert.ok(error instanceof StatusJsonParseError);
51+
assert.equal(error.status, 200);
52+
return true;
53+
},
54+
);
55+
});
56+
57+
it("rejects malformed successful JSON bodies", async () => {
58+
const client = new KeeperStatusClient({
59+
baseURL: "http://keeper.test",
60+
fetchImpl: mockFetch("{not-json"),
61+
});
62+
await assert.rejects(
63+
() => client.getStatus(),
64+
(error: unknown) => {
65+
assert.ok(error instanceof StatusJsonParseError);
66+
assert.match(error.message, /invalid JSON/i);
67+
return true;
68+
},
69+
);
70+
});
71+
});
72+
73+
describe("KeeperStatusClient non-success responses", () => {
74+
it("preserves typed StatusApiError for non-2xx JSON errors", async () => {
75+
const client = new KeeperStatusClient({
76+
baseURL: "http://keeper.test",
77+
fetchImpl: mockFetch(JSON.stringify({ error: "round not found" }), 404),
78+
});
79+
await assert.rejects(
80+
() => client.getRound(7),
81+
(error: unknown) => {
82+
assert.ok(error instanceof StatusApiError);
83+
assert.equal(error.status, 404);
84+
assert.equal(error.data.error, "round not found");
85+
return true;
86+
},
87+
);
88+
});
89+
90+
it("preserves StatusApiError when non-2xx bodies are malformed JSON", async () => {
91+
const client = new KeeperStatusClient({
92+
baseURL: "http://keeper.test",
93+
fetchImpl: mockFetch("not-json", 503),
94+
});
95+
await assert.rejects(
96+
() => client.getHealth(),
97+
(error: unknown) => {
98+
assert.ok(error instanceof StatusApiError);
99+
assert.equal(error.status, 503);
100+
assert.equal(error.data.error, "invalid JSON body");
101+
return true;
102+
},
103+
);
104+
});
105+
});

packages/sdk/src/status-client.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,42 @@ export class StatusApiError extends Error {
2727
}
2828
}
2929

30+
/** Raised when a successful HTTP response body is empty or not valid JSON. */
31+
export class StatusJsonParseError extends Error {
32+
readonly name = "StatusJsonParseError";
33+
readonly status: number;
34+
35+
constructor(status: number, options?: ErrorOptions) {
36+
super(`status api returned ${status} with invalid JSON body`, options);
37+
this.status = status;
38+
}
39+
}
40+
3041
function fullURL(base: string, path: string): string {
3142
const trimmed = base.replace(/\/+$/, "");
3243
const clean = path.startsWith("/") ? path : `/${path}`;
3344
return `${trimmed}${clean}`;
3445
}
3546

36-
async function asJson<T>(res: Response): Promise<T> {
47+
async function parseErrorBody(res: Response): Promise<ApiError> {
3748
const text = await res.text();
38-
if (!text.trim()) return {} as T;
49+
if (!text.trim()) return { error: `status api returned ${res.status}` };
3950
try {
40-
return JSON.parse(text) as T;
51+
return JSON.parse(text) as ApiError;
4152
} catch {
42-
return { error: "invalid JSON body" } as T;
53+
return { error: "invalid JSON body" };
54+
}
55+
}
56+
57+
async function parseSuccessBody<T>(res: Response): Promise<T> {
58+
const text = await res.text();
59+
if (!text.trim()) {
60+
throw new StatusJsonParseError(res.status);
61+
}
62+
try {
63+
return JSON.parse(text) as T;
64+
} catch (cause) {
65+
throw new StatusJsonParseError(res.status, { cause });
4366
}
4467
}
4568

@@ -82,10 +105,10 @@ export class KeeperStatusClient {
82105
headers: { Accept: "application/json", ...this.headers },
83106
});
84107
if (!res.ok) {
85-
const body = await asJson<ApiError>(res);
108+
const body = await parseErrorBody(res);
86109
throw new StatusApiError(res.status, body);
87110
}
88-
return asJson<T>(res);
111+
return parseSuccessBody<T>(res);
89112
}
90113
}
91114

0 commit comments

Comments
 (0)