Files
linker/service/spotify.js
T
paulandClaude Opus 4.8 5ceea28948 fix: single-flight Spotify token + 401-refresh-retry to survive cold-cache fan-out (closes task/spotify-token-resilience)
Source: task/spotify-token-resilience (plan/steward-linker-quality) — cold-cache fan-out fired up to 100 concurrent token POSTs (429 storm) and search didn't refresh-retry on a 401.

- service/spotify.js (new): makeSpotifyToken() single-flights the token fetch via an in-flight-promise cache so N concurrent cold/expired lookups share ONE POST /api/token; clears the promise on settle (success or failure) so a rejected fetch never poisons the cache. makeSpotifySearch() refreshes-and-retries once on a 401 and honors a bounded (capped) Retry-After on a 429, else throws (resolve-core nulls the candidate, as before).
- service/resolver.js: wires the factories with the real fetch + Date.now; value-cache + (expires_in-60)*1000 expiry math unchanged; external behavior (result shapes, USE_SPOTIFY degradation, /health, /resolve) identical.
- service/spotify.test.js (new): hermetic node:test coverage — single-flight (100 concurrent -> 1 fetch), re-fetch after expiry, failure clears in-flight, 401 refresh-retry + persistent-401 throw, bounded 429 backoff. Zero new deps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:30:12 +00:00

80 lines
3.9 KiB
JavaScript

// 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);
}