From f500c733729e3a08a791069e86c86a4952e7b698 Mon Sep 17 00:00:00 2001 From: Sash Zats Date: Sun, 25 Jan 2026 22:42:46 -0500 Subject: [PATCH 1/2] fix(cli): throw Error on timeout aborts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users have seen an elevated number of:\n clawdhub search image\n ✖ Non-error was thrown: "Timeout". You should only throw errors.\n\nInvestigation shows we were aborting with a string instead of an Error. Switching to controller.abort(new Error('Timeout')) makes retries/formatting treat it as a real error and clears the message.\n\nExample after change:\n clawdhub search image\n table-image v1.0.0 Table Image (0.332)\n nano-banana-pro v1.0.1 Nano Banana Pro (0.319)\n vap-media v1.0.1 AI media generation API - Flux2pro, Veo3.1, Suno Ai (0.281)\n clawdbot-meshyai-skill v0.1.0 Meshy AI (0.276)\n venice-ai-media v1.0.0 Venice AI Media (0.274)\n daily-recap v1.0.2 Daily Recap (0.260)\n openai-image-gen v1.0.1 Openai Image Gen (0.260)\n bible-votd v1.0.1 Bible Verse of the Day (0.248)\n orf v1.0.1 ORF (0.224)\n smalltalk v1.0.1 Smalltalk (0.161) --- e2e/clawdhub.e2e.test.ts | 2 +- packages/clawdhub/src/http.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/e2e/clawdhub.e2e.test.ts b/e2e/clawdhub.e2e.test.ts index 436510d..bf0b2db 100644 --- a/e2e/clawdhub.e2e.test.ts +++ b/e2e/clawdhub.e2e.test.ts @@ -47,7 +47,7 @@ async function makeTempConfig(registry: string, token: string | null) { async function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit) { const controller = new AbortController() - const timeout = setTimeout(() => controller.abort('Timeout'), REQUEST_TIMEOUT_MS) + const timeout = setTimeout(() => controller.abort(new Error('Timeout')), REQUEST_TIMEOUT_MS) try { return await fetch(input, { ...init, signal: controller.signal }) } finally { diff --git a/packages/clawdhub/src/http.ts b/packages/clawdhub/src/http.ts index 9c72a59..647b8f5 100644 --- a/packages/clawdhub/src/http.ts +++ b/packages/clawdhub/src/http.ts @@ -54,7 +54,7 @@ export async function apiRequest( body = JSON.stringify(args.body ?? {}) } const controller = new AbortController() - const timeout = setTimeout(() => controller.abort('Timeout'), REQUEST_TIMEOUT_MS) + const timeout = setTimeout(() => controller.abort(new Error('Timeout')), REQUEST_TIMEOUT_MS) const response = await fetch(url, { method: args.method, headers, @@ -103,7 +103,7 @@ export async function apiRequestForm( const headers: Record = { Accept: 'application/json' } if (args.token) headers.Authorization = `Bearer ${args.token}` const controller = new AbortController() - const timeout = setTimeout(() => controller.abort('Timeout'), REQUEST_TIMEOUT_MS) + const timeout = setTimeout(() => controller.abort(new Error('Timeout')), REQUEST_TIMEOUT_MS) const response = await fetch(url, { method: args.method, headers, @@ -138,7 +138,7 @@ export async function downloadZip(registry: string, args: { slug: string; versio } const controller = new AbortController() - const timeout = setTimeout(() => controller.abort('Timeout'), REQUEST_TIMEOUT_MS) + const timeout = setTimeout(() => controller.abort(new Error('Timeout')), REQUEST_TIMEOUT_MS) const response = await fetch(url.toString(), { method: 'GET', signal: controller.signal }) clearTimeout(timeout) if (!response.ok) { From 8665d447386c3d12de817904466729ba37efdf14 Mon Sep 17 00:00:00 2001 From: Sash Zats Date: Mon, 26 Jan 2026 12:36:07 +0000 Subject: [PATCH 2/2] fix(http): wrap fetch calls in try-finally to prevent timer leaks Addresses Vercel review comment: clearTimeout was not called on error paths when fetch throws an exception. --- packages/clawdhub/src/http.ts | 81 +++++++++++++++++++---------------- 1 file changed, 45 insertions(+), 36 deletions(-) diff --git a/packages/clawdhub/src/http.ts b/packages/clawdhub/src/http.ts index 647b8f5..1a6b668 100644 --- a/packages/clawdhub/src/http.ts +++ b/packages/clawdhub/src/http.ts @@ -55,22 +55,25 @@ export async function apiRequest( } const controller = new AbortController() const timeout = setTimeout(() => controller.abort(new Error('Timeout')), REQUEST_TIMEOUT_MS) - const response = await fetch(url, { - method: args.method, - headers, - body, - signal: controller.signal, - }) - clearTimeout(timeout) - if (!response.ok) { - const text = await response.text().catch(() => '') - const message = text || `HTTP ${response.status}` - if (response.status === 429 || response.status >= 500) { - throw new Error(message) + try { + const response = await fetch(url, { + method: args.method, + headers, + body, + signal: controller.signal, + }) + if (!response.ok) { + const text = await response.text().catch(() => '') + const message = text || `HTTP ${response.status}` + if (response.status === 429 || response.status >= 500) { + throw new Error(message) + } + throw new AbortError(message) } - throw new AbortError(message) + return (await response.json()) as unknown + } finally { + clearTimeout(timeout) } - return (await response.json()) as unknown }, { retries: 2 }, ) @@ -104,22 +107,25 @@ export async function apiRequestForm( if (args.token) headers.Authorization = `Bearer ${args.token}` const controller = new AbortController() const timeout = setTimeout(() => controller.abort(new Error('Timeout')), REQUEST_TIMEOUT_MS) - const response = await fetch(url, { - method: args.method, - headers, - body: args.form, - signal: controller.signal, - }) - clearTimeout(timeout) - if (!response.ok) { - const text = await response.text().catch(() => '') - const message = text || `HTTP ${response.status}` - if (response.status === 429 || response.status >= 500) { - throw new Error(message) + try { + const response = await fetch(url, { + method: args.method, + headers, + body: args.form, + signal: controller.signal, + }) + if (!response.ok) { + const text = await response.text().catch(() => '') + const message = text || `HTTP ${response.status}` + if (response.status === 429 || response.status >= 500) { + throw new Error(message) + } + throw new AbortError(message) } - throw new AbortError(message) + return (await response.json()) as unknown + } finally { + clearTimeout(timeout) } - return (await response.json()) as unknown }, { retries: 2 }, ) @@ -139,16 +145,19 @@ export async function downloadZip(registry: string, args: { slug: string; versio const controller = new AbortController() const timeout = setTimeout(() => controller.abort(new Error('Timeout')), REQUEST_TIMEOUT_MS) - const response = await fetch(url.toString(), { method: 'GET', signal: controller.signal }) - clearTimeout(timeout) - if (!response.ok) { - const message = (await response.text().catch(() => '')) || `HTTP ${response.status}` - if (response.status === 429 || response.status >= 500) { - throw new Error(message) + try { + const response = await fetch(url.toString(), { method: 'GET', signal: controller.signal }) + if (!response.ok) { + const message = (await response.text().catch(() => '')) || `HTTP ${response.status}` + if (response.status === 429 || response.status >= 500) { + throw new Error(message) + } + throw new AbortError(message) } - throw new AbortError(message) + return new Uint8Array(await response.arrayBuffer()) + } finally { + clearTimeout(timeout) } - return new Uint8Array(await response.arrayBuffer()) }, { retries: 2 }, )