fix(agents): bound provider JSON response reads (#95218)

This commit is contained in:
Alix-007
2026-06-23 08:33:38 +08:00
committed by GitHub
parent fee8ab4764
commit 2592f8a51a
2 changed files with 70 additions and 3 deletions
+52
View File
@@ -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<Uint8Array>({
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,
+18 -3
View File
@@ -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<string, unknown> | 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<T>(response: Response, label: string): Promise<T> {
/**
* 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<T>(
response: Response,
label: string,
opts?: { maxBytes?: number },
): Promise<T> {
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 });
}