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 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-09 23:43:31 +02:00
1 parent 76271c1e54
commit d2620519aa
1 file changed
+11 -6
+11 -6
View File
@@ -67,6 +67,14 @@ function parseSpotifyUrl(url) {
return { type: m[1], id: m[2] }; 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 _spTokenCache = null;
let _spTokenExpiry = 0; let _spTokenExpiry = 0;
@@ -81,14 +89,13 @@ async function fetchSpotifyToken() {
} }
const basic = Buffer.from(`${CONFIG.spotifyClientId}:${CONFIG.spotifyClientSecret}`).toString('base64'); 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', method: 'POST',
headers: { headers: {
'Authorization': `Basic ${basic}`, 'Authorization': `Basic ${basic}`,
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
}, },
body: 'grant_type=client_credentials', body: 'grant_type=client_credentials',
signal: AbortSignal.timeout(10000),
}); });
if (!r.ok) { if (!r.ok) {
@@ -101,16 +108,14 @@ async function fetchSpotifyToken() {
_spTokenCache = d.access_token; _spTokenCache = d.access_token;
_spTokenExpiry = Date.now() + (d.expires_in * 1000) - 30000; _spTokenExpiry = Date.now() + (d.expires_in * 1000) - 30000;
console.log('[spotify] token ok');
return _spTokenCache; return _spTokenCache;
} }
async function _spApi(path, token) { async function _spApi(path, token) {
const url = path.startsWith('http') ? path : `https://api.spotify.com/v1${path}`; const url = path.startsWith('http') ? path : `https://api.spotify.com/v1${path}`;
for (let attempt = 0; attempt < 4; attempt++) { for (let attempt = 0; attempt < 4; attempt++) {
const r = await fetch(url, { const r = await _fetchTimeout(url, { headers: { Authorization: `Bearer ${token}` } });
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(10000),
});
if (r.status === 429) { if (r.status === 429) {
const wait = (parseInt(r.headers.get('Retry-After') || '5', 10) + 1) * 1000; const wait = (parseInt(r.headers.get('Retry-After') || '5', 10) + 1) * 1000;
await new Promise(res => setTimeout(res, wait)); await new Promise(res => setTimeout(res, wait));