From a876f8d073cfefe48bf67d0e448edeae9f07c601 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 19 Jun 2026 16:50:23 +0200 Subject: [PATCH] fix(qqbot): bound chunked upload error bodies --- .../src/engine/api/media-chunked.test.ts | 91 +++++++++++++++++++ .../qqbot/src/engine/api/media-chunked.ts | 7 +- 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/extensions/qqbot/src/engine/api/media-chunked.test.ts b/extensions/qqbot/src/engine/api/media-chunked.test.ts index 617a4beaf67..175608105a5 100644 --- a/extensions/qqbot/src/engine/api/media-chunked.test.ts +++ b/extensions/qqbot/src/engine/api/media-chunked.test.ts @@ -101,6 +101,28 @@ function stubFetchOk(): ReturnType { return fetchWithSsrFGuardMock; } +function cancelTrackedResponse( + text: string, + init: ResponseInit, +): { + response: Response; + wasCanceled: () => boolean; +} { + let canceled = false; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + }, + cancel() { + canceled = true; + }, + }); + return { + response: new Response(stream, init), + wasCanceled: () => canceled, + }; +} + // ============ Tests ============ describe("media-chunked: UploadDailyLimitExceededError", () => { @@ -259,6 +281,75 @@ describe("media-chunked: ChunkedMediaApi.uploadChunked", () => { expect(last.totalBytes).toBe(FIXTURE_BUFFER.length); }); + it("bounds COS PUT error bodies without using response.text()", async () => { + const client = mockApiClient(); + const tm = mockTokenManager(); + const logger = { info: vi.fn(), error: vi.fn(), warn: vi.fn() }; + client.request.mockImplementation(async (_token, _method, pathLocal) => { + if (pathLocal.endsWith("/upload_prepare")) { + return makePrepareResponse("uid-bounded", 1); + } + throw new Error(`unexpected path ${pathLocal}`); + }); + + const releases = [vi.fn(async () => {}), vi.fn(async () => {}), vi.fn(async () => {})]; + const trackedResponses = releases.map((release) => { + const tracked = cancelTrackedResponse(`${"cos gateway unavailable ".repeat(1024)}tail`, { + status: 503, + statusText: "Service Unavailable", + headers: { + "content-type": "text/plain", + "x-cos-request-id": "req-bounded", + }, + }); + const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded")); + return { + response: tracked.response, + wasCanceled: tracked.wasCanceled, + release, + textSpy, + }; + }); + const pendingResponses = [...trackedResponses]; + + fetchWithSsrFGuardMock.mockImplementation(async () => { + const next = pendingResponses.shift(); + if (!next) { + throw new Error("unexpected extra COS PUT attempt"); + } + return { + response: next.response, + release: next.release, + }; + }); + + const api = new ChunkedMediaApi(client, tm, { logger }); + let error: unknown; + try { + await api.uploadChunked({ + scope: "group", + targetId: "g1", + fileType: MediaFileType.FILE, + source: { kind: "buffer", buffer: Buffer.from("01234567"), fileName: "blob.bin" }, + creds: { appId: "a", clientSecret: "s" }, + }); + } catch (caught) { + error = caught; + } + + expect(String(error)).toContain("COS PUT failed: 503 Service Unavailable"); + expect(String(error)).toContain("cos gateway unavailable"); + expect(String(error)).not.toContain("tail"); + expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(3); + for (const tracked of trackedResponses) { + expect(tracked.wasCanceled()).toBe(true); + expect(tracked.textSpy).not.toHaveBeenCalled(); + expect(tracked.release).toHaveBeenCalledTimes(1); + } + expect(JSON.stringify(logger.error.mock.calls)).toContain("cos gateway unavailable"); + expect(JSON.stringify(logger.error.mock.calls)).not.toContain("tail"); + }); + it("maps UPLOAD_PREPARE_FALLBACK_CODE to UploadDailyLimitExceededError", async () => { const client = mockApiClient(); const tm = mockTokenManager(); diff --git a/extensions/qqbot/src/engine/api/media-chunked.ts b/extensions/qqbot/src/engine/api/media-chunked.ts index 8152b8b89d1..6db4d16b172 100644 --- a/extensions/qqbot/src/engine/api/media-chunked.ts +++ b/extensions/qqbot/src/engine/api/media-chunked.ts @@ -36,6 +36,7 @@ import * as crypto from "node:crypto"; import type { FileHandle } from "node:fs/promises"; +import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import type { MediaSource, OpenedLocalFile } from "../messaging/media-source.js"; import { openLocalFile } from "../messaging/media-source.js"; @@ -139,6 +140,7 @@ const MAX_PART_FINISH_RETRY_TIMEOUT_MS = 10 * 60 * 1000; /** Per-part PUT timeout (5 minutes). Matches the low-bandwidth tolerance. */ const PART_UPLOAD_TIMEOUT_MS = 300_000; +const PART_UPLOAD_ERROR_BODY_LIMIT_BYTES = 8 * 1024; /** * Boundary used by `md5_10m` — first 10,002,432 bytes. @@ -569,7 +571,10 @@ async function putToPresignedUrl( const etag = response.headers.get("ETag") ?? "-"; if (!response.ok) { - const body = await response.text().catch(() => ""); + const body = await readResponseTextLimited( + response, + PART_UPLOAD_ERROR_BODY_LIMIT_BYTES, + ).catch(() => ""); logger?.error?.( `${prefix} PUT part ${partIndex}/${totalParts}: HTTP ${response.status} ${response.statusText} (${elapsed}ms, requestId=${requestId}) body=${body.slice(0, 160)}`, );