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
* Please don't remove this comment while sharing this code.
*/
@ -50,7 +50,7 @@ class InnerTubePokeVidious {
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) {
return this.cache[v].result;
}
@ -59,82 +59,169 @@ class InnerTubePokeVidious {
"User-Agent": this.useragent,
};
// retry until success, but enforce a 5-second max retry window only if it fails
const fetchWithRetry = async (url, options = {}, maxRetryTime = 5000) => {
let lastError;
// Retry window is armed ONLY after seeing a 500 or 502 response
const fetchWithRetry = async (url, options = {}, maxRetryTime = 5000) => {
let lastError;
// retryable HTTP statuses
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
const RETRY_WINDOW_TRIGGER = new Set([500, 502]);
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
// backoff settings
const BASE_DELAY_MS = 120;
const MAX_DELAY_MS = 1000;
const MIN_DELAY_MS = 50;
const JITTER_FRAC = 0.2;
const BASE_DELAY_MS = 120;
const MAX_DELAY_MS = 1000;
const MIN_DELAY_MS = 50;
const JITTER_FRAC = 0.2;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const DEFAULT_PER_TRY_TIMEOUT = 2000;
let attempt = 0;
let retryStart = null;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
while (true) {
try {
const res = await fetch(url, {
...options,
headers: {
...options.headers,
...headers,
},
});
const callerSignal = options?.signal || null;
if (res.ok) {
return res; // success: return immediately
}
const attemptWithTimeout = async (timeoutMs) => {
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
if (!retryStart) retryStart = Date.now();
const onCallerAbort = () =>
controller.abort(
callerSignal.reason || new Error("Aborted by caller")
);
if (!RETRYABLE_STATUS.has(res.status)) {
return res; // non-retryable status, return immediately
}
if (callerSignal) {
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) {
throw new Error(`Fetch failed for ${url} after ${maxRetryTime}ms`);
}
// First attempt (no retry window yet)
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));
const jitter = rawDelay * JITTER_FRAC;
let delay = rawDelay + (Math.random() * 2 * jitter - jitter);
delay = Math.max(MIN_DELAY_MS, delay);
if (!RETRY_WINDOW_TRIGGER.has(firstRes.status)) {
return firstRes;
}
attempt++;
await sleep(delay);
continue;
} catch (err) {
lastError = err;
this?.initError?.(`Fetch error for ${url}`, err);
if (!retryStart) retryStart = Date.now();
if (Date.now() - retryStart >= maxRetryTime) {
lastError = new Error(`Initial ${firstRes.status} from ${url}`);
} catch (err) {
lastError = err;
this?.initError?.(`Fetch error for ${url}`, err);
throw lastError;
}
const rawDelay = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * Math.pow(2, attempt));
const jitter = rawDelay * JITTER_FRAC;
let delay = rawDelay + (Math.random() * 2 * jitter - jitter);
delay = Math.max(MIN_DELAY_MS, delay);
// Retry loop: window is ARMED due to 500/502
const retryStart = Date.now();
let attempt = 1;
attempt++;
await sleep(delay);
continue;
}
}
};
while (true) {
const elapsed = Date.now() - retryStart;
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 {
const [invComments, videoInfo] = await Promise.all([
@ -157,7 +244,8 @@ const fetchWithRetry = async (url, options = {}, maxRetryTime = 5000) => {
this.initError("Video info missing/unparsable", v);
return {
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 color2 = "#111827";
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(
`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;
} else {
this.initError( vid,`ID: ${v}` );
this.initError(vid, `ID: ${v}`);
}
} catch (error) {
this.initError(`Error getting video ${v}`, error);