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>
80 lines
3.9 KiB
JavaScript
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);
|
|
}
|