music poller: a 429 on recently-played pauses only the play log for the full Retry-After; now playing keeps polling every 5 s; player 429s honour Retry-After up to 24 h
All checks were successful
Deploy production / deploy (push) Successful in 1m8s

This commit is contained in:
2026-10-25 13:25:25 +00:00
parent 253cd69424
commit 628e5cb9b3

View File

@@ -23,10 +23,20 @@ import { getLiveFromDb } from './stats';
// off to 30 s when paused or idle, and honouring 429 Retry-After. Changes (new track,
// play/pause, seek) are pushed to browsers via ./live. Recently played (the play log) runs
// every minute, plus ~10 s after a track change so a finished track lands quickly.
// Spotify rate-limits endpoints separately (recently-played can be blocked for hours while
// currently-playing still answers), so a 429 on the play log only pauses the play log, for
// the full Retry-After, and never slows the now-playing card down.
const LEASE_SECONDS = 20; // renewed every tick; another container takes over 20 s after we stop
const RECENT_MS = 60_000;
const CATCH_UP_MS = 10_000;
const HOLDER = `app-${crypto.randomUUID().slice(0, 8)}`;
const MAX_RETRY_S = 24 * 3600;
const retryMs = (e: SpotifyError, fallback: number) =>
Math.min(MAX_RETRY_S, Math.max(5, e.retryAfter ?? fallback)) * 1000;
const rateLimited = (e: unknown): e is SpotifyError =>
e instanceof SpotifyError && e.status === 429;
const until = (ms: number) =>
new Date(Date.now() + ms).toISOString().slice(0, 16).replace('T', ' ') + ' UTC';
let timer: ReturnType<typeof setTimeout> | undefined;
let stopped = false;
@@ -35,6 +45,8 @@ let last: PlayerSample | null = null;
let idleTicks = 0;
let nextRecentAt = 0;
let catchUps = 0;
let recentBlockedUntil = 0;
let recentError: string | null = null;
let cache = newCache();
export const spotifyCredentials = () => ({
@@ -86,7 +98,8 @@ export async function tick(): Promise<number> {
}
last = sample;
idleTicks = sample.isPlaying ? 0 : idleTicks + 1;
if (Date.now() >= nextRecentAt) {
if (Date.now() >= nextRecentAt && Date.now() >= recentBlockedUntil) {
try {
const added = await pollRecent(client, access, cache);
// Spotify can take a little while to log a finished track: retry twice, 20 s apart.
if (!added && catchUps > 0) {
@@ -96,12 +109,23 @@ export async function tick(): Promise<number> {
catchUps = 0;
nextRecentAt = Date.now() + RECENT_MS;
}
} catch (e) {
if (!rateLimited(e)) throw e;
const wait = retryMs(e, 60);
recentBlockedUntil = Date.now() + wait;
catchUps = 0;
recentError = `${e.message} (play log paused until ${until(wait)})`;
}
}
if (Date.now() < recentBlockedUntil) error = recentError;
return nextDelay(sample.isPlaying, idleTicks);
} catch (e) {
error = await handlePollError(client, e).catch(() => String(e));
if (e instanceof SpotifyError && e.status === 429)
return Math.min(600, Math.max(5, e.retryAfter ?? 30)) * 1000;
if (rateLimited(e)) {
const wait = retryMs(e, 30);
error = `${error} (paused until ${until(wait)})`;
return wait;
}
return 30_000;
} finally {
log(error ?? '');