From 451700c20e31ea6b13a0076a389a3d370051f49f Mon Sep 17 00:00:00 2001 From: Dylan woo <13128589345@163.com> Date: Wed, 22 Apr 2026 10:59:57 +0800 Subject: [PATCH] fix: add AbortSignal timeouts to public-API adapter fetches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors #1106 (which added a 5s timeout to resolveTwitterQueryId in clis/twitter/shared.js): external fetches without a timeout can hang indefinitely under network stalls, and every blocking call in an adapter propagates into a hung CLI command. Adds AbortSignal.timeout(...) to eight external fetches across six adapters: - clis/spotify/spotify.js (3x, 10s) — token refresh, API calls, OAuth token exchange. These hit accounts.spotify.com and api.spotify.com on every command. - clis/substack/search.js (2x, 8s) — post + publication search against substack.com/api/v1. - clis/producthunt/utils.js (1x, 10s) — producthunt.com/feed RSS. Preserves the existing "return [] on network failure" graceful- degradation contract by wrapping the fetch in try/catch. - clis/sinablog/utils.js (1x, 5s) — per-article page fetch inside a hot-list loop. Highest blast radius: a single slow article blocks the entire command. Already wrapped in try/catch → timeout is graceful. - clis/sinablog/search.js (1x, 5s) — search.sina.com.cn/api. - clis/yahoo-finance/quote.js (1x, 5s) — query1.finance.yahoo.com chart API inside page.evaluate. Already wrapped in a strategy-1 try/catch → timeout falls through to strategy 2 (page DOM parse). AbortSignal.timeout(ms) is supported in Node 18+ (repo requires 21+) and Chrome 103+ (well below any currently-supported extension host). Timeout values chosen per endpoint class: 10s for auth/OAuth flows that can be legitimately slow, 8s for public API search, 5s for loops or best-effort fallback strategies where a failed fetch is expected to degrade gracefully rather than surface a hard error. Context: #1082 (twitter article hang) uncovered the class; #1106 fixed the twitter instance. The same pattern exists in several other adapters that make unbounded external calls. No new tests: the existing suite exercises all of these files (npm test = 231 files / 1870 passed). Adding behavioral tests for timeout would require either network simulation or refactoring the fetch to be injectable — neither proportionate to the 15-line change. --- clis/producthunt/utils.js | 8 +++++++- clis/sinablog/search.js | 1 + clis/sinablog/utils.js | 2 +- clis/spotify/spotify.js | 3 +++ clis/substack/search.js | 4 ++-- clis/yahoo-finance/quote.js | 2 +- 6 files changed, 15 insertions(+), 5 deletions(-) diff --git a/clis/producthunt/utils.js b/clis/producthunt/utils.js index dbeb638bd..7ccf862a2 100644 --- a/clis/producthunt/utils.js +++ b/clis/producthunt/utils.js @@ -27,7 +27,13 @@ export async function fetchFeed(category) { const url = category ? `https://www.producthunt.com/feed?category=${encodeURIComponent(category)}` : 'https://www.producthunt.com/feed'; - const resp = await fetch(url, { headers: { 'User-Agent': UA } }); + let resp; + try { + resp = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(10000) }); + } + catch { + return []; + } if (!resp.ok) return []; const xml = await resp.text(); diff --git a/clis/sinablog/search.js b/clis/sinablog/search.js index 13df48c4a..6caf64f5c 100644 --- a/clis/sinablog/search.js +++ b/clis/sinablog/search.js @@ -18,6 +18,7 @@ async function searchSinaBlog(keyword, limit) { 'User-Agent': 'Mozilla/5.0', Accept: 'application/json', }, + signal: AbortSignal.timeout(5000), }); if (!resp.ok) throw new Error(`Sina blog search failed: HTTP ${resp.status}`); diff --git a/clis/sinablog/utils.js b/clis/sinablog/utils.js index 027881cf1..2347a69e0 100644 --- a/clis/sinablog/utils.js +++ b/clis/sinablog/utils.js @@ -96,7 +96,7 @@ export async function loadSinaBlogHot(page, limit) { url: item.url, }; try { - const resp = await fetch(item.url, { credentials: 'include' }); + const resp = await fetch(item.url, { credentials: 'include', signal: AbortSignal.timeout(5000) }); if (resp.ok) { const html = await resp.text(); const doc = new DOMParser().parseFromString(html, 'text/html'); diff --git a/clis/spotify/spotify.js b/clis/spotify/spotify.js index af0286739..98c903f27 100644 --- a/clis/spotify/spotify.js +++ b/clis/spotify/spotify.js @@ -50,6 +50,7 @@ async function refreshAccessToken(refreshToken) { Authorization: 'Basic ' + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64'), }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken }), + signal: AbortSignal.timeout(10000), }); if (!res.ok) { const err = await res.json().catch(() => ({})); @@ -82,6 +83,7 @@ async function api(method, path, body) { method, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(10000), }); if (res.status === 204 || res.status === 202) return null; @@ -133,6 +135,7 @@ cli({ Authorization: 'Basic ' + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64'), }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI }), + signal: AbortSignal.timeout(10000), }); if (!tokenRes.ok) { const err = await tokenRes.json().catch(() => ({})); diff --git a/clis/substack/search.js b/clis/substack/search.js index 00f4debc5..f1eee341d 100644 --- a/clis/substack/search.js +++ b/clis/substack/search.js @@ -21,7 +21,7 @@ async function searchPosts(keyword, limit) { url.searchParams.set('query', keyword); url.searchParams.set('page', '0'); url.searchParams.set('includePlatformResults', 'true'); - const resp = await fetch(url, { headers: headers() }); + const resp = await fetch(url, { headers: headers(), signal: AbortSignal.timeout(8000) }); if (!resp.ok) throw new CommandExecutionError(`Substack post search failed: HTTP ${resp.status}`); const data = await resp.json(); @@ -39,7 +39,7 @@ async function searchPublications(keyword, limit) { const url = new URL('https://substack.com/api/v1/profile/search'); url.searchParams.set('query', keyword); url.searchParams.set('page', '0'); - const resp = await fetch(url, { headers: headers() }); + const resp = await fetch(url, { headers: headers(), signal: AbortSignal.timeout(8000) }); if (!resp.ok) throw new CommandExecutionError(`Substack publication search failed: HTTP ${resp.status}`); const data = await resp.json(); diff --git a/clis/yahoo-finance/quote.js b/clis/yahoo-finance/quote.js index 7754059fa..7953eeaba 100644 --- a/clis/yahoo-finance/quote.js +++ b/clis/yahoo-finance/quote.js @@ -23,7 +23,7 @@ cli({ // Strategy 1: v8 chart API try { const chartUrl = 'https://query1.finance.yahoo.com/v8/finance/chart/' + encodeURIComponent(sym) + '?interval=1d&range=1d'; - const resp = await fetch(chartUrl); + const resp = await fetch(chartUrl, { signal: AbortSignal.timeout(5000) }); if (resp.ok) { const d = await resp.json(); const chart = d?.chart?.result?.[0];