page-retry
Version:
Automatic retry and proxy fallback for Playwright powered by Aluvia.
214 lines (213 loc) • 9.35 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.AluviaError = void 0;
exports.retryWithProxy = retryWithProxy;
const aluvia_ts_sdk_1 = __importDefault(require("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 = {}));
class AluviaError extends Error {
constructor(message, code) {
super(message);
this.name = "AluviaError";
this.code = code;
}
}
exports.AluviaError = AluviaError;
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_ts_sdk_1.default(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");
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();
},
};
}