fix(dev): cap Discord smoke response bodies

This commit is contained in:
Vincent Koc
2026-05-30 00:39:15 +02:00
parent 60673b03bc
commit ba3eae5518
2 changed files with 115 additions and 9 deletions

View File

@@ -15,6 +15,7 @@ import {
redactForDevToolLog,
redactHomePath,
} from "../lib/dev-tooling-safety.ts";
import { readBoundedResponseText } from "../lib/bounded-response.ts";
function writeStdoutLine(message: string): void {
process.stdout.write(`${message}\n`);
@@ -135,6 +136,7 @@ type FailureResult = {
const DISCORD_API_BASE = "https://discord.com/api/v10";
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
const DEFAULT_OPENCLAW_CLI_TIMEOUT_MS = 60_000;
const DISCORD_RESPONSE_BODY_MAX_BYTES = 1024 * 1024;
const WEBHOOK_CLEANUP_TIMEOUT_MS = 10_000;
function sleep(ms: number): Promise<void> {
@@ -185,6 +187,41 @@ function parseNumber(value: string | undefined, fallback: number, label: string)
return parseStrictIntegerOption({ fallback, label, min: 1, raw: value });
}
function createDiscordResponseTooLargeError(message: string): Error {
const error = new Error(message);
(error as NodeJS.ErrnoException).code = "ETOOBIG";
return error;
}
function isTooLargeError(error: unknown): boolean {
return (error as NodeJS.ErrnoException | undefined)?.code === "ETOOBIG";
}
async function readDiscordResponseText(params: {
response: Response;
label: string;
signal: AbortSignal;
maxBytes: number;
}): Promise<string> {
return await readBoundedResponseText(params.response, params.label, params.maxBytes, {
createTooLargeError: createDiscordResponseTooLargeError,
signal: params.signal,
});
}
async function readDiscordResponseJson(params: {
response: Response;
label: string;
signal: AbortSignal;
maxBytes: number;
}): Promise<unknown> {
const text = await readDiscordResponseText(params);
if (!text) {
return {};
}
return JSON.parse(text);
}
function resolveStateDir(): string {
const override = process.env.OPENCLAW_STATE_DIR?.trim();
if (override) {
@@ -458,12 +495,14 @@ async function requestDiscordJson<T>(params: {
retries?: number;
timeoutMs?: number;
errorPrefix: string;
responseBodyMaxBytes?: number;
fetchImpl?: typeof fetch;
sleepImpl?: (ms: number) => Promise<void>;
}): Promise<T> {
const retries = params.retries ?? 6;
const fetchImpl = params.fetchImpl ?? fetch;
const sleepImpl = params.sleepImpl ?? sleep;
const responseBodyMaxBytes = params.responseBodyMaxBytes ?? DISCORD_RESPONSE_BODY_MAX_BYTES;
const deadlineMs = Date.now() + (params.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
const timeoutError = () =>
new Error(
@@ -488,7 +527,17 @@ async function requestDiscordJson<T>(params: {
if (response.status === 429) {
const bodyTimeoutMs = remainingTimeoutMs(deadlineMs);
const body = (await withTimeout({
operation: response.json().catch(() => ({})),
operation: readDiscordResponseJson({
response,
label: `${params.errorPrefix} ${params.method} ${redactDiscordApiPath(params.path)}`,
signal: controller.signal,
maxBytes: responseBodyMaxBytes,
}).catch((error) => {
if (isTooLargeError(error)) {
throw error;
}
return {};
}),
timeoutMs: bodyTimeoutMs,
timeoutError,
onTimeout: () => controller.abort(),
@@ -501,7 +550,12 @@ async function requestDiscordJson<T>(params: {
if (!response.ok) {
const bodyTimeoutMs = remainingTimeoutMs(deadlineMs);
const text = await withTimeout({
operation: response.text().catch(() => ""),
operation: readDiscordResponseText({
response,
label: `${params.errorPrefix} ${params.method} ${redactDiscordApiPath(params.path)}`,
signal: controller.signal,
maxBytes: responseBodyMaxBytes,
}),
timeoutMs: bodyTimeoutMs,
timeoutError,
onTimeout: () => controller.abort(),
@@ -519,7 +573,12 @@ async function requestDiscordJson<T>(params: {
const bodyTimeoutMs = remainingTimeoutMs(deadlineMs);
return (await withTimeout({
operation: response.json(),
operation: readDiscordResponseJson({
response,
label: `${params.errorPrefix} ${params.method} ${redactDiscordApiPath(params.path)}`,
signal: controller.signal,
maxBytes: responseBodyMaxBytes,
}),
timeoutMs: bodyTimeoutMs,
timeoutError,
onTimeout: () => controller.abort(),
@@ -988,7 +1047,9 @@ async function main(): Promise<number> {
export const testing = {
parseDriverMode,
parseNumber,
DISCORD_RESPONSE_BODY_MAX_BYTES,
redactDiscordApiPath,
readDiscordResponseText,
remainingTimeoutMs,
requestDiscordJson,
resolveStateDir,

View File

@@ -97,12 +97,12 @@ describe("script-specific dev tooling hardening", () => {
});
it("times out stalled Discord smoke response body reads", async () => {
const response = {
ok: true,
status: 200,
statusText: "OK",
json: () => new Promise(() => {}),
} as Response;
const response = new Response(
new ReadableStream({
start() {},
}),
{ status: 200, statusText: "OK" },
);
const request = discordSmokeTesting.requestDiscordJson({
method: "GET",
path: "/channels/123/messages",
@@ -118,6 +118,51 @@ describe("script-specific dev tooling hardening", () => {
);
});
it("bounds Discord smoke response bodies by content-length", async () => {
const response = new Response("{}", {
headers: { "content-length": "6" },
});
const request = discordSmokeTesting.requestDiscordJson({
method: "GET",
path: "/channels/123/messages",
headers: {},
retries: 0,
timeoutMs: 50,
responseBodyMaxBytes: 5,
errorPrefix: "Discord API",
fetchImpl: (() => Promise.resolve(response)) as typeof fetch,
});
await expect(request).rejects.toThrow(
"Discord API GET /channels/123/messages response body exceeded 5 bytes",
);
});
it("bounds Discord smoke response bodies by streamed bytes", async () => {
const response = new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(6));
controller.close();
},
}),
);
const request = discordSmokeTesting.requestDiscordJson({
method: "GET",
path: "/channels/123/messages",
headers: {},
retries: 0,
timeoutMs: 50,
responseBodyMaxBytes: 5,
errorPrefix: "Discord API",
fetchImpl: (() => Promise.resolve(response)) as typeof fetch,
});
await expect(request).rejects.toThrow(
"Discord API GET /channels/123/messages response body exceeded 5 bytes",
);
});
it("does not launch another Discord smoke retry after the timeout budget expires", async () => {
let calls = 0;
const response = {