From 586732f15b7d65d260f061bcfa0cf5c2aaf6b1f3 Mon Sep 17 00:00:00 2001 From: ashley Date: Mon, 22 Sep 2025 13:21:35 +0200 Subject: [PATCH] retry window is armed ONLY after seeing a 500 or 502 response + format file --- src/libpoketube/libpoketube-core.js | 218 ++++++++++++++++++++-------- 1 file changed, 154 insertions(+), 64 deletions(-) diff --git a/src/libpoketube/libpoketube-core.js b/src/libpoketube/libpoketube-core.js index 3a98aa2a..a6b5ce27 100644 --- a/src/libpoketube/libpoketube-core.js +++ b/src/libpoketube/libpoketube-core.js @@ -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);