retry window is armed ONLY after seeing a 500 or 502 response + format file

This commit is contained in:
ashley 2025-09-22 13:21:35 +02:00
parent b6684cf08a
commit 586732f15b

View File

@ -1,7 +1,7 @@
/** /**
* Poke is a Free/Libre youtube front-end ! * Poke is a Free/Libre YouTube front-end!
* *
* This file is Licensed under LGPL-3.0-or-later. Poketube itself is GPL, Only this file is LGPL. * This file is Licensed under LGPL-3.0-or-later. Poketube itself is GPL, only this file is LGPL.
* See a copy here: https://www.gnu.org/licenses/lgpl-3.0.txt * See a copy here: https://www.gnu.org/licenses/lgpl-3.0.txt
* Please don't remove this comment while sharing this code. * Please don't remove this comment while sharing this code.
*/ */
@ -50,7 +50,7 @@ class InnerTubePokeVidious {
return { error: true, message: "No video ID provided" }; return { error: true, message: "No video ID provided" };
} }
// If cached result exists and is less than 1 hour old, return it // If cached result exists and is less than 1 hour old, return it
if (this.cache[v] && Date.now() - this.cache[v].timestamp < 3600000) { if (this.cache[v] && Date.now() - this.cache[v].timestamp < 3600000) {
return this.cache[v].result; return this.cache[v].result;
} }
@ -59,82 +59,169 @@ class InnerTubePokeVidious {
"User-Agent": this.useragent, "User-Agent": this.useragent,
}; };
// retry until success, but enforce a 5-second max retry window only if it fails // Retry window is armed ONLY after seeing a 500 or 502 response
const fetchWithRetry = async (url, options = {}, maxRetryTime = 5000) => { const fetchWithRetry = async (url, options = {}, maxRetryTime = 5000) => {
let lastError; let lastError;
// retryable HTTP statuses const RETRY_WINDOW_TRIGGER = new Set([500, 502]);
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]); const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
// backoff settings const BASE_DELAY_MS = 120;
const BASE_DELAY_MS = 120; const MAX_DELAY_MS = 1000;
const MAX_DELAY_MS = 1000; const MIN_DELAY_MS = 50;
const MIN_DELAY_MS = 50; const JITTER_FRAC = 0.2;
const JITTER_FRAC = 0.2;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const DEFAULT_PER_TRY_TIMEOUT = 2000;
let attempt = 0; const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let retryStart = null;
while (true) { const callerSignal = options?.signal || null;
try {
const res = await fetch(url, {
...options,
headers: {
...options.headers,
...headers,
},
});
if (res.ok) { const attemptWithTimeout = async (timeoutMs) => {
return res; // success: return immediately const controller = new AbortController();
} const timer = setTimeout(
() => controller.abort(new Error("Fetch attempt timed out")),
Math.max(1, timeoutMs)
);
// only start timing if we need to retry const onCallerAbort = () =>
if (!retryStart) retryStart = Date.now(); controller.abort(
callerSignal.reason || new Error("Aborted by caller")
);
if (!RETRYABLE_STATUS.has(res.status)) { if (callerSignal) {
return res; // non-retryable status, return immediately if (callerSignal.aborted) {
} controller.abort(
callerSignal.reason || new Error("Aborted by caller")
);
} else {
callerSignal.addEventListener("abort", onCallerAbort, {
once: true,
});
}
}
this?.initError?.(`Retrying fetch for ${url}`, res.status); try {
const res = await fetch(url, {
...options,
headers: {
...options?.headers,
...headers,
},
signal: controller.signal,
});
return res;
} finally {
clearTimeout(timer);
if (callerSignal)
callerSignal.removeEventListener("abort", onCallerAbort);
}
};
if (Date.now() - retryStart >= maxRetryTime) { // First attempt (no retry window yet)
throw new Error(`Fetch failed for ${url} after ${maxRetryTime}ms`); try {
} const firstRes = await attemptWithTimeout(DEFAULT_PER_TRY_TIMEOUT);
if (firstRes.ok) return firstRes;
const rawDelay = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * Math.pow(2, attempt)); if (!RETRY_WINDOW_TRIGGER.has(firstRes.status)) {
const jitter = rawDelay * JITTER_FRAC; return firstRes;
let delay = rawDelay + (Math.random() * 2 * jitter - jitter); }
delay = Math.max(MIN_DELAY_MS, delay);
attempt++; lastError = new Error(`Initial ${firstRes.status} from ${url}`);
await sleep(delay); } catch (err) {
continue; lastError = err;
} catch (err) { this?.initError?.(`Fetch error for ${url}`, err);
lastError = err;
this?.initError?.(`Fetch error for ${url}`, err);
if (!retryStart) retryStart = Date.now();
if (Date.now() - retryStart >= maxRetryTime) {
throw lastError; throw lastError;
} }
const rawDelay = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * Math.pow(2, attempt)); // Retry loop: window is ARMED due to 500/502
const jitter = rawDelay * JITTER_FRAC; const retryStart = Date.now();
let delay = rawDelay + (Math.random() * 2 * jitter - jitter); let attempt = 1;
delay = Math.max(MIN_DELAY_MS, delay);
attempt++; while (true) {
await sleep(delay); const elapsed = Date.now() - retryStart;
continue; const remaining = maxRetryTime - elapsed;
}
}
};
if (remaining <= 0) {
throw (
lastError ||
new Error(`Fetch failed for ${url} after ${maxRetryTime}ms`)
);
}
const perTryTimeout = Math.max(
50,
Math.min(DEFAULT_PER_TRY_TIMEOUT, remaining - 25)
);
try {
const res = await attemptWithTimeout(perTryTimeout);
if (res.ok) {
return res;
}
if (!RETRYABLE_STATUS.has(res.status)) {
return res;
}
this?.initError?.(`Retrying fetch for ${url}`, res.status);
const raw = Math.min(
MAX_DELAY_MS,
BASE_DELAY_MS * Math.pow(2, attempt)
);
const jitter = raw * JITTER_FRAC;
let delay = raw + (Math.random() * 2 * jitter - jitter);
delay = Math.max(
MIN_DELAY_MS,
Math.min(delay, Math.max(0, remaining - 10))
);
if (delay <= 0) {
lastError = new Error(
`Fetch failed for ${url} after ${maxRetryTime}ms (no window left)`
);
throw lastError;
}
attempt += 1;
await sleep(delay);
continue;
} catch (err) {
lastError = err;
if (callerSignal && callerSignal.aborted) {
throw lastError;
}
const nowRemaining = maxRetryTime - (Date.now() - retryStart);
if (nowRemaining <= 0) {
throw lastError;
}
const raw = Math.min(
MAX_DELAY_MS,
BASE_DELAY_MS * Math.pow(2, attempt)
);
const jitter = raw * JITTER_FRAC;
let delay = raw + (Math.random() * 2 * jitter - jitter);
delay = Math.max(
MIN_DELAY_MS,
Math.min(delay, Math.max(0, nowRemaining - 10))
);
if (delay <= 0) {
throw lastError;
}
this?.initError?.(`Fetch error for ${url}`, err);
attempt += 1;
await sleep(delay);
continue;
}
}
};
try { try {
const [invComments, videoInfo] = await Promise.all([ const [invComments, videoInfo] = await Promise.all([
@ -157,7 +244,8 @@ const fetchWithRetry = async (url, options = {}, maxRetryTime = 5000) => {
this.initError("Video info missing/unparsable", v); this.initError("Video info missing/unparsable", v);
return { return {
error: true, error: true,
message: "Sorry nya, we couldn't find any information about that video qwq", message:
"Sorry nya, we couldn't find any information about that video qwq",
}; };
} }
@ -172,7 +260,9 @@ const fetchWithRetry = async (url, options = {}, maxRetryTime = 5000) => {
let color = "#0ea5e9"; let color = "#0ea5e9";
let color2 = "#111827"; let color2 = "#111827";
try { try {
// `sqp` is a URL parameter used by YouTube thumbnail/image servers to request a specific scale, crop or quality profile (base64-encoded), controlling how the thumbnail is sized or compressed. // `sqp` is a URL parameter used by YouTube thumbnail/image servers
// to request a specific scale, crop or quality profile (base64-encoded),
// controlling how the thumbnail is sized or compressed.
const palette = await getColors( const palette = await getColors(
`https://i.ytimg.com/vi/${v}/hqdefault.jpg?sqp=${this.sqp}` `https://i.ytimg.com/vi/${v}/hqdefault.jpg?sqp=${this.sqp}`
); );
@ -200,7 +290,7 @@ const fetchWithRetry = async (url, options = {}, maxRetryTime = 5000) => {
return this.cache[v].result; return this.cache[v].result;
} else { } else {
this.initError( vid,`ID: ${v}` ); this.initError(vid, `ID: ${v}`);
} }
} catch (error) { } catch (error) {
this.initError(`Error getting video ${v}`, error); this.initError(`Error getting video ${v}`, error);