From 72688f66a072c2344707c0fb6b487ec6b062ec5c Mon Sep 17 00:00:00 2001 From: jomeswang <1551403343@qq.com> Date: Fri, 7 Aug 2026 14:45:22 +0800 Subject: [PATCH] fix(http): preserve Agnes error response bodies --- src/http/requestJson.ts | 42 ++++++++++++++++++++++++- test/unit/request-json.test.mjs | 54 +++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 test/unit/request-json.test.mjs diff --git a/src/http/requestJson.ts b/src/http/requestJson.ts index f0849ef..b3ac14e 100644 --- a/src/http/requestJson.ts +++ b/src/http/requestJson.ts @@ -13,7 +13,7 @@ export async function requestJson( for (let attempt = 0; attempt <= retries; attempt += 1) { try { const response = await fetchImpl(url, init); - const raw = await response.json(); + const raw = await readResponseBody(response); return { response, raw }; } catch (error) { lastError = error; @@ -33,6 +33,46 @@ export async function requestJson( }); } +async function readResponseBody(response: Response): Promise { + const text = await response.text(); + if (!text.trim()) return null; + + let body: unknown = text; + try { + body = JSON.parse(text); + } catch { + // Agnes occasionally returns plain-text or HTML error bodies. + } + return response.ok ? body : sanitizeResponseBody(body); +} + +function sanitizeResponseBody(value: unknown, depth = 0): unknown { + if (depth >= 20) return "[TRUNCATED]"; + if (typeof value === "string") { + return value + .replace(/\bBearer\s+[^\s<]+/gi, "Bearer [REDACTED]") + .replace(/\bsk-[A-Za-z0-9_-]+\b/g, "[REDACTED]") + .replace( + /([?&](?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password)=)[^&#\s]+/gi, + "$1[REDACTED]", + ); + } + if (Array.isArray(value)) { + return value.map((entry) => sanitizeResponseBody(entry, depth + 1)); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + /^(?:(?:client|refresh|access|x)[_-])?(?:authorization|api[_-]?key|token|secret|password)$/i.test(key) + ? "[REDACTED]" + : sanitizeResponseBody(entry, depth + 1), + ]), + ); + } + return value; +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/test/unit/request-json.test.mjs b/test/unit/request-json.test.mjs new file mode 100644 index 0000000..d145aff --- /dev/null +++ b/test/unit/request-json.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { requestJson } from "../../dist/http/requestJson.js"; + +test("preserves and sanitizes a JSON HTTP error body", async () => { + const fetchImpl = async () => + new Response( + JSON.stringify({ + error: { message: "unsupported resolution and frame count" }, + api_key: "sk-secret-value", + authorization: "Bearer secret-token", + client_secret: "client-secret-value", + refresh_token: "refresh-token-value", + callback: "https://example.test/callback?token=secret-query-value", + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + + const { response, raw } = await requestJson(fetchImpl, "https://example.test", {}); + + assert.equal(response.status, 400); + assert.deepEqual(raw, { + error: { message: "unsupported resolution and frame count" }, + api_key: "[REDACTED]", + authorization: "[REDACTED]", + client_secret: "[REDACTED]", + refresh_token: "[REDACTED]", + callback: "https://example.test/callback?token=[REDACTED]", + }); +}); + +test("preserves a plain-text HTTP error body without retrying", async () => { + let calls = 0; + const fetchImpl = async () => { + calls += 1; + return new Response("invalid request for sk-secret-value", { status: 400 }); + }; + + const { response, raw } = await requestJson(fetchImpl, "https://example.test", {}); + + assert.equal(response.status, 400); + assert.equal(raw, "invalid request for [REDACTED]"); + assert.equal(calls, 1); +}); + +test("represents an empty HTTP error body as null", async () => { + const fetchImpl = async () => new Response(null, { status: 502 }); + + const { response, raw } = await requestJson(fetchImpl, "https://example.test", {}); + + assert.equal(response.status, 502); + assert.equal(raw, null); +});