From 2592f8a51a4e99a127eb076d4a18974c62c979fa Mon Sep 17 00:00:00 2001
From: Alix-007
Date: Tue, 23 Jun 2026 08:33:38 +0800
Subject: [PATCH] fix(agents): bound provider JSON response reads (#95218)
---
src/agents/provider-http-errors.test.ts | 52 +++++++++++++++++++++++++
src/agents/provider-http-errors.ts | 21 ++++++++--
2 files changed, 70 insertions(+), 3 deletions(-)
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 });
}