From d2620519aa164a5b00075a69222040c5a7195a74 Mon Sep 17 00:00:00 2001 From: Honey Date: Sat, 9 May 2026 23:43:31 +0200 Subject: [PATCH] fix: replace AbortSignal.timeout with AbortController+setTimeout for reliable fetch timeouts undici (Node.js native fetch) silently ignores AbortSignal.timeout when TCP connect is blocked. Using AbortController driven by Node's setTimeout guarantees the abort fires and the hanging request is cancelled within 10s. Adds [spotify] token ok log to confirm when credentials work. Co-Authored-By: Claude Sonnet 4.6 --- backend/novasonic-server.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index 6f1a13e..c7322b2 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -67,6 +67,14 @@ function parseSpotifyUrl(url) { return { type: m[1], id: m[2] }; } +// AbortSignal.timeout() is unreliable in Node/undici when TCP connect is blocked. +// Use AbortController + setTimeout instead — always fires from Node's event loop. +function _fetchTimeout(url, opts, ms = 10000) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), ms); + return fetch(url, { ...opts, signal: ctrl.signal }).finally(() => clearTimeout(timer)); +} + let _spTokenCache = null; let _spTokenExpiry = 0; @@ -81,14 +89,13 @@ async function fetchSpotifyToken() { } const basic = Buffer.from(`${CONFIG.spotifyClientId}:${CONFIG.spotifyClientSecret}`).toString('base64'); - const r = await fetch('https://accounts.spotify.com/api/token', { + const r = await _fetchTimeout('https://accounts.spotify.com/api/token', { method: 'POST', headers: { 'Authorization': `Basic ${basic}`, 'Content-Type': 'application/x-www-form-urlencoded', }, body: 'grant_type=client_credentials', - signal: AbortSignal.timeout(10000), }); if (!r.ok) { @@ -101,16 +108,14 @@ async function fetchSpotifyToken() { _spTokenCache = d.access_token; _spTokenExpiry = Date.now() + (d.expires_in * 1000) - 30000; + console.log('[spotify] token ok'); return _spTokenCache; } async function _spApi(path, token) { const url = path.startsWith('http') ? path : `https://api.spotify.com/v1${path}`; for (let attempt = 0; attempt < 4; attempt++) { - const r = await fetch(url, { - headers: { Authorization: `Bearer ${token}` }, - signal: AbortSignal.timeout(10000), - }); + const r = await _fetchTimeout(url, { headers: { Authorization: `Bearer ${token}` } }); if (r.status === 429) { const wait = (parseInt(r.headers.get('Retry-After') || '5', 10) + 1) * 1000; await new Promise(res => setTimeout(res, wait));