fix(qqbot): bound chunked upload error bodies

This commit is contained in:
Vincent Koc
2026-06-19 16:50:23 +02:00
parent 0a3e0d081d
commit a876f8d073
2 changed files with 97 additions and 1 deletions

View File

@@ -101,6 +101,28 @@ function stubFetchOk(): ReturnType<typeof vi.fn> {
return fetchWithSsrFGuardMock;
}
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
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();

View File

@@ -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)}`,
);