UNPKG

page-retry

Version:

Automatic retry and proxy fallback for Playwright powered by Aluvia.

206 lines (205 loc) 8.99 kB
import Aluvia from "aluvia-ts-sdk"; const DEFAULT_GOTO_TIMEOUT_MS = 15000; const ENV_MAX_RETRIES = Math.max(0, parseInt(process.env.ALUVIA_MAX_RETRIES || "1", 10)); // prettier-ignore const ENV_BACKOFF_MS = Math.max(0, parseInt(process.env.ALUVIA_BACKOFF_MS || "300", 10)); // prettier-ignore const ENV_RETRY_ON = (process.env.ALUVIA_RETRY_ON ?? "ECONNRESET,ETIMEDOUT,net::ERR,Timeout") .split(",") .map((value) => value.trim()) .filter(Boolean); /* Pre-compile retry patterns for performance & correctness */ const DEFAULT_RETRY_PATTERNS = ENV_RETRY_ON.map((value) => value.startsWith("/") && value.endsWith("/") ? new RegExp(value.slice(1, -1)) : value); var AluviaErrorCode; (function (AluviaErrorCode) { AluviaErrorCode["NoApiKey"] = "ALUVIA_NO_API_KEY"; AluviaErrorCode["NoProxy"] = "ALUVIA_NO_PROXIES"; AluviaErrorCode["ProxyFetchFailed"] = "ALUVIA_PROXY_FETCH_FAILED"; AluviaErrorCode["InsufficientBalance"] = "ALUVIA_INSUFFICIENT_BALANCE"; AluviaErrorCode["BalanceFetchFailed"] = "ALUVIA_BALANCE_FETCH_FAILED"; })(AluviaErrorCode || (AluviaErrorCode = {})); export class AluviaError extends Error { constructor(message, code) { super(message); this.name = "AluviaError"; this.code = code; } } let aluviaClient; async function getAluviaProxy() { const apiKey = process.env.ALUVIA_API_KEY || ""; if (!apiKey) { throw new AluviaError("Missing ALUVIA_API_KEY environment variable.", AluviaErrorCode.NoApiKey); } aluviaClient ?? (aluviaClient = new Aluvia(apiKey)); const proxy = await aluviaClient.first(); if (!proxy) { throw new AluviaError("Failed to obtain a proxy for retry attempts. Check your balance and proxy pool at https://dashboard.aluvia.io/.", AluviaErrorCode.NoProxy); } return { server: `http://${proxy.host}:${proxy.httpPort}`, username: proxy.username, password: proxy.password, }; } async function getAluviaBalance() { const apiKey = process.env.ALUVIA_API_KEY || ""; if (!apiKey) { throw new AluviaError("Missing ALUVIA_API_KEY environment variable.", AluviaErrorCode.NoApiKey); } const response = await fetch("https://api.aluvia.io/account/status", { headers: { Authorization: `Bearer ${apiKey}`, }, }); if (!response.ok) { throw new AluviaError(`Failed to fetch Aluvia account status: ${response.status} ${response.statusText}`, AluviaErrorCode.BalanceFetchFailed); } const data = await response.json(); return data.data.balance_gb; } function backoffDelay(base, attempt) { // exponential + jitter const jitter = Math.random() * 100; return base * Math.pow(2, attempt) + jitter; } function compileRetryable(patterns = DEFAULT_RETRY_PATTERNS) { return (err) => { if (!err) return false; const msg = String(err?.message ?? err ?? ""); const code = String(err?.code ?? ""); const name = String(err?.name ?? ""); return patterns.some((p) => p instanceof RegExp ? p.test(msg) || p.test(code) || p.test(name) : msg.includes(p) || code.includes(p) || name.includes(p)); }; } function inferBrowserTypeFromPage(page) { const browser = page.context().browser(); const browserType = browser?.browserType?.(); if (!browserType) { throw new Error("Cannot infer BrowserType from page"); } return browserType; } async function inferContextDefaults(page) { const context = page.context(); const options = context._options; return options ?? {}; } function inferLaunchDefaults(page) { const browser = page.context().browser(); const options = browser._options; return options ?? {}; } async function relaunchWithProxy(proxy, oldPage, closeOldBrowser = true) { const browserType = inferBrowserTypeFromPage(oldPage); const launchDefaults = inferLaunchDefaults(oldPage); const contextDefaults = await inferContextDefaults(oldPage); if (closeOldBrowser) { const oldBrowser = oldPage.context().browser(); try { await oldBrowser?.close(); } catch { } } const retryLaunch = { ...launchDefaults, proxy, }; const browser = await browserType.launch(retryLaunch); const context = await browser.newContext(contextDefaults); const page = await context.newPage(); return { page }; } const GOTO_ORIGINAL = Symbol.for("aluvia.gotoOriginal"); export function retryWithProxy(page, options) { const { maxRetries = ENV_MAX_RETRIES, backoffMs = ENV_BACKOFF_MS, retryOn = DEFAULT_RETRY_PATTERNS, closeOldBrowser = true, proxyProvider, onRetry, onProxyLoaded, } = options ?? {}; const isRetryable = compileRetryable(retryOn); /** Prefer unpatched goto to avoid recursion */ const getRawGoto = (p) => (p[GOTO_ORIGINAL]?.bind(p) ?? p.goto.bind(p)); return { async goto(url, gotoOptions) { const run = async () => { let basePage = page; let lastErr; // First attempt without proxy try { const response = await getRawGoto(basePage)(url, { ...(gotoOptions ?? {}), timeout: gotoOptions?.timeout ?? DEFAULT_GOTO_TIMEOUT_MS, waitUntil: gotoOptions?.waitUntil ?? "domcontentloaded", }); return { response: response ?? null, page: basePage }; } catch (err) { lastErr = err; if (!isRetryable(err)) { throw err; } } if (!proxyProvider) { const balance = await getAluviaBalance().catch(() => null); if (balance !== null && balance <= 0) { throw new AluviaError("Your Aluvia account has no remaining balance. Please top up at https://dashboard.aluvia.io/ to continue using proxies.", AluviaErrorCode.InsufficientBalance); } } const proxy = await (proxyProvider?.get() ?? getAluviaProxy()).catch((err) => { lastErr = err; return undefined; }); if (!proxy) { throw new AluviaError("Failed to obtain a proxy for retry attempts. Check your balance and proxy pool at https://dashboard.aluvia.io/.", AluviaErrorCode.ProxyFetchFailed); } else { await onProxyLoaded?.(proxy); } // Retries with proxy for (let attempt = 1; attempt <= maxRetries; attempt++) { if (backoffMs > 0) { const delay = backoffDelay(backoffMs, attempt - 1); await new Promise((resolve) => setTimeout(resolve, delay)); } await onRetry?.(attempt, maxRetries, lastErr); try { const { page: newPage } = await relaunchWithProxy(proxy, basePage, closeOldBrowser); try { const response = await getRawGoto(newPage)(url, { ...(gotoOptions ?? {}), timeout: gotoOptions?.timeout ?? DEFAULT_GOTO_TIMEOUT_MS, waitUntil: gotoOptions?.waitUntil ?? "domcontentloaded", }); // non-fatal readiness gate try { await newPage.waitForFunction(() => typeof document !== "undefined" && !!document.title?.trim(), { timeout: DEFAULT_GOTO_TIMEOUT_MS }); } catch { } return { response: response ?? null, page: newPage, }; } catch (err) { // navigation on the new page failed — carry this page forward basePage = newPage; lastErr = err; // next loop iteration will close this browser (since we pass basePage) continue; } } catch (err) { // relaunch itself failed (no new page created) lastErr = err; continue; } } if (lastErr instanceof Error) { throw lastErr; } throw new Error(lastErr ? String(lastErr) : "Navigation failed"); }; return run(); }, }; }