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>
This commit is contained in:
paul
2026-06-23 18:30:12 +00:00
co-authored by Claude Opus 4.8
parent 8ac0666a71
commit 5ceea28948
3 changed files with 287 additions and 22 deletions
+31 -22
View File
@@ -18,6 +18,7 @@ import { readFileSync } from "node:fs";
import { pickArtist, pickAlbum } from "./lib.js";
import { buildClassifyMessages, parseClassification, DEFAULT_MODEL } from "./llm.js";
import { resolveAll as resolveAllCore } from "./resolve-core.js";
import { makeSpotifyToken, makeSpotifySearch } from "./spotify.js";
// --- minimal .env loader (no dependency) ------------------------------------
try {
@@ -42,38 +43,46 @@ const USE_LLM = Boolean(OLLAMA_URL);
const spCache = new Map();
// --- Spotify (optional): direct-artist-link lookup --------------------------
let spToken = null;
let spTokenExp = 0;
async function spotifyToken() {
if (spToken && Date.now() < spTokenExp) return spToken;
const auth = Buffer.from(`${SPOTIFY_ID}:${SPOTIFY_SECRET}`).toString("base64");
const r = await fetch("https://accounts.spotify.com/api/token", {
method: "POST",
headers: { authorization: `Basic ${auth}`, "content-type": "application/x-www-form-urlencoded" },
body: "grant_type=client_credentials",
// The single-flight token getter: a cold/expired-cache fan-out (up to MAX_CANDIDATES concurrent
// resolves) shares ONE POST /api/token instead of stampeding the rate-limited token endpoint.
// fetchToken does the real POST; the cache + (expires_in-60)*1000 expiry math live in the factory.
const spTokens = makeSpotifyToken({
fetchToken: async () => {
const auth = Buffer.from(`${SPOTIFY_ID}:${SPOTIFY_SECRET}`).toString("base64");
const r = await fetch("https://accounts.spotify.com/api/token", {
method: "POST",
headers: { authorization: `Basic ${auth}`, "content-type": "application/x-www-form-urlencoded" },
body: "grant_type=client_credentials",
signal: AbortSignal.timeout(5000), // fail fast on a hung upstream (caught per-candidate -> null)
});
if (!r.ok) throw new Error(`spotify token http ${r.status}`);
return r.json();
},
});
// One bare Spotify search (no token mgmt): returns { ok, status, headers, body } so the
// search-with-retry layer can branch on 401/429 without an exception masking the status.
async function doSpotifySearch(name, token) {
const url = `https://api.spotify.com/v1/search?type=artist,album&limit=5&q=${encodeURIComponent(name)}`;
const r = await fetch(url, {
headers: { authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(5000), // fail fast on a hung upstream (caught per-candidate -> null)
});
if (!r.ok) throw new Error(`spotify token http ${r.status}`);
const j = await r.json();
spToken = j.access_token;
spTokenExp = Date.now() + (j.expires_in - 60) * 1000;
return spToken;
// 401/429 carry no usable body for our purposes; only parse JSON on a real hit.
const body = r.ok ? await r.json() : null;
return { ok: r.ok, status: r.status, headers: r.headers, body };
}
// 401 -> invalidate + refresh + retry once; 429 -> bounded Retry-After + retry once; else throw.
const spSearch = makeSpotifySearch({ doSearch: doSpotifySearch, tokens: spTokens });
// The injected `search` for resolve-core: one type=artist,album Spotify search per candidate,
// returning the exact-name-match hits as { artist, album } where each is the external Spotify url
// (string) or null. No creds -> both null (nothing links). resolve-core's pickDirect applies the
// kindHint tie-break and shapes the link; the cache (spCache) is owned by resolve-core too.
async function search(name) {
if (!USE_SPOTIFY) return { artist: null, album: null };
const token = await spotifyToken();
const url = `https://api.spotify.com/v1/search?type=artist,album&limit=5&q=${encodeURIComponent(name)}`;
const r = await fetch(url, {
headers: { authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(5000), // fail fast on a hung upstream (caught per-candidate -> null)
});
if (!r.ok) throw new Error(`spotify search http ${r.status}`);
const j = await r.json();
const j = await spSearch(name);
return {
artist: pickArtist(name, j.artists?.items)?.external_urls?.spotify ?? null,
album: pickAlbum(name, j.albums?.items)?.external_urls?.spotify ?? null,