mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-30 04:33:36 +00:00
fix(qqbot): bound chunked upload error bodies
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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)}`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user