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:
+31
-22
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user