// Testable Spotify client-credentials token + search helpers, decoupled from resolver.js's HTTP // side effects so `node --test` can drive them with injected fakes (a counting fetch + a fake // clock). The single-flight token getter coalesces a cold-cache fan-out — up to MAX_CANDIDATES // concurrent resolves share ONE POST /api/token instead of stampeding the rate-limited endpoint. // Single-flight token getter factory. `fetchToken()` performs the real POST /api/token and // returns { access_token, expires_in } (resolver.js wires the live fetch); `now` is the clock // (Date.now, injectable for tests). Returns { getToken, invalidate }: // getToken() -> the cached access_token while it is fresh; on a cold/expired cache, the FIRST // caller starts the fetch and every concurrent caller awaits the SAME in-flight promise, so // N concurrent cold lookups make exactly one fetch. The in-flight promise is cleared once it // settles (success OR failure) so the next cold window re-fetches and a rejected fetch never // poisons the cache. // invalidate() -> drop the cached value so the next getToken re-fetches (used by the 401 path). // Expiry math is (expires_in - 60)*1000 ms — a 60s safety margin before Spotify's stated expiry. export function makeSpotifyToken({ fetchToken, now = Date.now }) { let token = null; let exp = 0; let inflight = null; async function getToken() { if (token && now() < exp) return token; // warm hit — value cache, no promise if (inflight) return inflight; // a cold fetch is already running — coalesce onto it inflight = (async () => { const j = await fetchToken(); token = j.access_token; exp = now() + (j.expires_in - 60) * 1000; return token; })().finally(() => { inflight = null; }); // clear on success OR failure return inflight; } // Force the next getToken to re-fetch (the cached value is rejected mid-window, e.g. a 401). function invalidate() { token = null; exp = 0; } return { getToken, invalidate }; } // Search-with-retry factory. `doSearch(name, token)` performs ONE Spotify search and returns // { ok, status, body } — never throws for an HTTP error (the caller decides). `tokens` is a // makeSpotifyToken() handle; `sleep(ms)` is the backoff delay (injectable, zero in tests). // Returns search(name) -> the parsed JSON body, or throws so resolve-core's per-candidate catch // nulls that candidate (today's graceful degradation). Behavior: // * 401 -> invalidate the token, fetch a fresh one, retry ONCE; a persistent 401 throws. // * 429 -> honor a bounded Retry-After (capped) once, then retry ONCE; a persistent 429 throws. // * other !ok -> throw (caller nulls the candidate, as today). // Retries are bounded to one — never unbounded. export function makeSpotifySearch({ doSearch, tokens, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), maxRetryAfterMs = 2000 }) { return async function search(name) { let token = await tokens.getToken(); let r = await doSearch(name, token); if (r.status === 401) { tokens.invalidate(); // the token was rejected mid-window — force a fresh one token = await tokens.getToken(); r = await doSearch(name, token); } else if (r.status === 429) { const wait = retryAfterMs(r.headers, maxRetryAfterMs); if (wait != null) await sleep(wait); r = await doSearch(name, token); } if (!r.ok) throw new Error(`spotify search http ${r.status}`); return r.body; }; } // Parse a bounded Retry-After (seconds) from response headers into ms, capped at `maxMs`. // `headers` is a fetch Headers (or any object with a .get). Returns null when absent/garbage. export function retryAfterMs(headers, maxMs) { const raw = headers?.get?.("retry-after"); if (raw == null || raw === "") return null; // absent -> no wait (Number(null/"") is 0, guard it) const secs = Number(raw); if (!Number.isFinite(secs) || secs < 0) return null; return Math.min(secs * 1000, maxMs); }