Skip to content

Commit a15f8e3

Browse files
committed
fix(agents): bound provider JSON response reads
1 parent e89c255 commit a15f8e3

2 files changed

Lines changed: 70 additions & 3 deletions

File tree

src/agents/provider-http-errors.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,32 @@ function createStreamingBinaryResponse(params: {
3838
};
3939
}
4040

41+
function createStreamingJsonResponse(params: { chunkCount: number; chunkSize: number }): {
42+
response: Response;
43+
getReadCount: () => number;
44+
} {
45+
// Streaming fixture proves oversized JSON reads stop before buffering everything.
46+
let reads = 0;
47+
const encoder = new TextEncoder();
48+
const stream = new ReadableStream<Uint8Array>({
49+
pull(controller) {
50+
if (reads >= params.chunkCount) {
51+
controller.close();
52+
return;
53+
}
54+
reads += 1;
55+
controller.enqueue(encoder.encode("a".repeat(params.chunkSize)));
56+
},
57+
});
58+
return {
59+
response: new Response(stream, {
60+
status: 200,
61+
headers: { "Content-Type": "application/json" },
62+
}),
63+
getReadCount: () => reads,
64+
};
65+
}
66+
4167
describe("provider error utils", () => {
4268
it("formats nested provider error details with request ids", async () => {
4369
const response = new Response(
@@ -211,6 +237,32 @@ describe("provider error utils", () => {
211237
);
212238
});
213239

240+
it("parses well-formed JSON responses under the byte cap", async () => {
241+
const response = new Response(JSON.stringify({ models: ["a", "b"] }), {
242+
status: 200,
243+
headers: { "content-type": "application/json" },
244+
});
245+
246+
await expect(
247+
readProviderJsonResponse<{ models: string[] }>(response, "Provider catalog failed"),
248+
).resolves.toEqual({ models: ["a", "b"] });
249+
});
250+
251+
it("caps successful JSON responses instead of buffering oversized bodies", async () => {
252+
const streamed = createStreamingJsonResponse({
253+
chunkCount: 20,
254+
chunkSize: 1024,
255+
});
256+
257+
await expect(
258+
readProviderJsonResponse(streamed.response, "Provider catalog failed", {
259+
maxBytes: 2048,
260+
}),
261+
).rejects.toThrow("Provider catalog failed: JSON response exceeds 2048 bytes");
262+
263+
expect(streamed.getReadCount()).toBeLessThan(20);
264+
});
265+
214266
it("caps successful binary responses instead of buffering oversized bodies", async () => {
215267
const streamed = createStreamingBinaryResponse({
216268
chunkCount: 20,

src/agents/provider-http-errors.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export { normalizeOptionalString as trimToUndefined } from "../../packages/norma
1313

1414
const ERROR_BODY_METADATA_LIMIT = 500;
1515
const PROVIDER_BINARY_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
16+
const PROVIDER_JSON_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
1617

1718
/** Returns a plain object view for provider JSON payloads when one exists. */
1819
export function asObject(value: unknown): Record<string, unknown> | undefined {
@@ -287,10 +288,24 @@ export async function assertOkOrThrowHttpError(response: Response, label: string
287288
throw await createProviderHttpError(response, label, { statusPrefix: "HTTP " });
288289
}
289290

290-
/** Parses a provider JSON response and wraps malformed JSON with the caller's label. */
291-
export async function readProviderJsonResponse<T>(response: Response, label: string): Promise<T> {
291+
/**
292+
* Parses a provider JSON response under a byte cap and wraps malformed JSON with the caller's label.
293+
*
294+
* The body is read through the same bounded reader as binary responses so a provider that streams an
295+
* unbounded JSON body cannot force the runtime to buffer the whole payload before parsing.
296+
*/
297+
export async function readProviderJsonResponse<T>(
298+
response: Response,
299+
label: string,
300+
opts?: { maxBytes?: number },
301+
): Promise<T> {
302+
const maxBytes = opts?.maxBytes ?? PROVIDER_JSON_RESPONSE_MAX_BYTES;
303+
const bytes = await readResponseWithLimit(response, maxBytes, {
304+
onOverflow: ({ maxBytes: maxBytesLocal }) =>
305+
new Error(`${label}: JSON response exceeds ${maxBytesLocal} bytes`),
306+
});
292307
try {
293-
return (await response.json()) as T;
308+
return JSON.parse(new TextDecoder().decode(bytes)) as T;
294309
} catch (cause) {
295310
throw new Error(`${label}: malformed JSON response`, { cause });
296311
}

0 commit comments

Comments
 (0)