diff --git a/src/agents/provider-http-errors.test.ts b/src/agents/provider-http-errors.test.ts index 4c3ab7b8454d..6a843fd827f3 100644 --- a/src/agents/provider-http-errors.test.ts +++ b/src/agents/provider-http-errors.test.ts @@ -38,6 +38,32 @@ function createStreamingBinaryResponse(params: { }; } +function createStreamingJsonResponse(params: { chunkCount: number; chunkSize: number }): { + response: Response; + getReadCount: () => number; +} { + // Streaming fixture proves oversized JSON reads stop before buffering everything. + let reads = 0; + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + pull(controller) { + if (reads >= params.chunkCount) { + controller.close(); + return; + } + reads += 1; + controller.enqueue(encoder.encode("a".repeat(params.chunkSize))); + }, + }); + return { + response: new Response(stream, { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + getReadCount: () => reads, + }; +} + describe("provider error utils", () => { it("formats nested provider error details with request ids", async () => { const response = new Response( @@ -211,6 +237,32 @@ describe("provider error utils", () => { ); }); + it("parses well-formed JSON responses under the byte cap", async () => { + const response = new Response(JSON.stringify({ models: ["a", "b"] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + + await expect( + readProviderJsonResponse<{ models: string[] }>(response, "Provider catalog failed"), + ).resolves.toEqual({ models: ["a", "b"] }); + }); + + it("caps successful JSON responses instead of buffering oversized bodies", async () => { + const streamed = createStreamingJsonResponse({ + chunkCount: 20, + chunkSize: 1024, + }); + + await expect( + readProviderJsonResponse(streamed.response, "Provider catalog failed", { + maxBytes: 2048, + }), + ).rejects.toThrow("Provider catalog failed: JSON response exceeds 2048 bytes"); + + expect(streamed.getReadCount()).toBeLessThan(20); + }); + it("caps successful binary responses instead of buffering oversized bodies", async () => { const streamed = createStreamingBinaryResponse({ chunkCount: 20, diff --git a/src/agents/provider-http-errors.ts b/src/agents/provider-http-errors.ts index 7099b3fd49e1..dbfe7c06ed3d 100644 --- a/src/agents/provider-http-errors.ts +++ b/src/agents/provider-http-errors.ts @@ -13,6 +13,7 @@ export { normalizeOptionalString as trimToUndefined } from "../../packages/norma const ERROR_BODY_METADATA_LIMIT = 500; const PROVIDER_BINARY_RESPONSE_MAX_BYTES = 16 * 1024 * 1024; +const PROVIDER_JSON_RESPONSE_MAX_BYTES = 16 * 1024 * 1024; /** Returns a plain object view for provider JSON payloads when one exists. */ export function asObject(value: unknown): Record | undefined { @@ -287,10 +288,24 @@ export async function assertOkOrThrowHttpError(response: Response, label: string throw await createProviderHttpError(response, label, { statusPrefix: "HTTP " }); } -/** Parses a provider JSON response and wraps malformed JSON with the caller's label. */ -export async function readProviderJsonResponse(response: Response, label: string): Promise { +/** + * Parses a provider JSON response under a byte cap and wraps malformed JSON with the caller's label. + * + * The body is read through the same bounded reader as binary responses so a provider that streams an + * unbounded JSON body cannot force the runtime to buffer the whole payload before parsing. + */ +export async function readProviderJsonResponse( + response: Response, + label: string, + opts?: { maxBytes?: number }, +): Promise { + const maxBytes = opts?.maxBytes ?? PROVIDER_JSON_RESPONSE_MAX_BYTES; + const bytes = await readResponseWithLimit(response, maxBytes, { + onOverflow: ({ maxBytes: maxBytesLocal }) => + new Error(`${label}: JSON response exceeds ${maxBytesLocal} bytes`), + }); try { - return (await response.json()) as T; + return JSON.parse(new TextDecoder().decode(bytes)) as T; } catch (cause) { throw new Error(`${label}: malformed JSON response`, { cause }); }