v0.3: fail-fast timeout, retry con jitter, recupero sessione, messaggi di errore chiari

This commit is contained in:
Matteo Benedetto
2026-08-12 16:14:04 +02:00
parent 2824d2e654
commit 5170b35744
+127 -39
View File
@@ -39,6 +39,10 @@ const BRIDGE_PY =
const DEFAULT_TIMEOUT_MS = 120_000; // comandi BiDi lunghi (navigate wait:"complete")
const STATUS_TIMEOUT_MS = 5_000;
const EVAL_TIMEOUT_MS = 30_000; // fail-fast per operazioni veloci
const SCREENSHOT_TIMEOUT_MS = 30_000;
const EVENTS_TIMEOUT_MS = 10_000;
const SEND_TIMEOUT_MS = 60_000;
const MAX_BRIDGE_CYCLES = 2; // cicli di riavvio bridge+firefox su sessione orfana
let bridgeProcess: ReturnType<typeof spawn> | null = null;
@@ -68,6 +72,14 @@ async function fetchWithTimeout(
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
return await fetch(url, { ...init, signal: ac.signal });
} catch (err) {
if (ac.signal.aborted) {
throw new Error(
`timeout dopo ${timeoutMs}ms: ${url} non ha risposto (bridge bloccato o Firefox non risponde). ` +
`Verifica lo stato con browser_status.`,
);
}
throw err;
} finally {
clearTimeout(timer);
}
@@ -78,17 +90,26 @@ async function bridge(
init?: RequestInit,
timeoutMs: number = DEFAULT_TIMEOUT_MS,
): Promise<any> {
const res = await fetchWithTimeout(
`${BRIDGE_URL}${path}`,
{
headers: {
"content-type": "application/json",
authorization: `Bearer ${BRIDGE_TOKEN}`,
let res: Response;
try {
res = await fetchWithTimeout(
`${BRIDGE_URL}${path}`,
{
headers: {
"content-type": "application/json",
authorization: `Bearer ${BRIDGE_TOKEN}`,
},
...init,
},
...init,
},
timeoutMs,
);
timeoutMs,
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(
`bridge non raggiungibile (${BRIDGE_URL}): ${msg}. ` +
`Avvia il bridge con browser_start prima di usare i comandi browser_*.`,
);
}
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new BridgeError(res.status, path, body);
return body;
@@ -97,7 +118,14 @@ async function bridge(
function isTransient(err: unknown): boolean {
if (err instanceof BridgeError) return err.status >= 500;
const msg = err instanceof Error ? err.message : String(err);
return /ECONNREFUSED|fetch failed|abort|timeout|socket hang up|session not created/i.test(msg);
// NOTA: "session not created" NON è transitorio: richiede il riavvio
// della sessione (gestito da withSessionRecovery), non un semplice retry.
return /ECONNREFUSED|fetch failed|abort|timeout|socket hang up/i.test(msg);
}
function isSessionError(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err);
return /session not created|sessione BiDi chiusa|session\.end/i.test(msg);
}
async function withRetry<T>(fn: () => Promise<T>, label: string, retries = 2): Promise<T> {
@@ -108,12 +136,16 @@ async function withRetry<T>(fn: () => Promise<T>, label: string, retries = 2): P
} catch (err) {
lastErr = err;
if (!isTransient(err) || attempt === retries) break;
const delay = 500 * 2 ** attempt;
// backoff esponenziale con jitter (±50%) per evitare thundering herd
const base = 500 * 2 ** attempt;
const delay = Math.round(base * (0.5 + Math.random() * 0.5));
log(`${label}: tentativo ${attempt + 1} fallito (${(err as Error).message}), retry tra ${delay}ms`);
await new Promise((r) => setTimeout(r, delay));
}
}
throw lastErr;
throw new Error(
`${label} fallito dopo ${retries + 1} tentativi: ${(lastErr as Error).message}`,
);
}
// ---------------------------------------------------------------------------
@@ -151,11 +183,15 @@ async function waitBridgeReady(timeoutMs = 15_000): Promise<boolean> {
* Garantisce bridge attivo. Se non risponde: avvia Firefox (profilo scelto) +
* bridge. Se il bridge parte ma muore (es. sessione BiDi orfana dopo un kill
* senza session.end), riavvia Firefox e riprova fino a MAX_BRIDGE_CYCLES.
* Con forceRestart=true riavvia comunque (usato dopo errori di sessione).
*/
async function ensureBridge(profile: "dedicated" | "main" = "dedicated"): Promise<void> {
if (await bridgeAlive()) return;
async function ensureBridge(
profile: "dedicated" | "main" = "dedicated",
opts: { forceRestart?: boolean } = {},
): Promise<void> {
if (!opts.forceRestart && (await bridgeAlive())) return;
for (let cycle = 0; cycle <= MAX_BRIDGE_CYCLES; cycle++) {
if (cycle > 0) {
if (cycle > 0 || opts.forceRestart) {
log(`ciclo ${cycle}: riavvio Firefox (possibile sessione BiDi orfana)`);
await execFileAsync(SCRIPT, ["stop"]).catch(() => {});
await new Promise((r) => setTimeout(r, 1_500));
@@ -173,6 +209,22 @@ async function ensureBridge(profile: "dedicated" | "main" = "dedicated"): Promis
);
}
/**
* Esegue fn; se fallisce per sessione BiDi non valida (es. "session not
* created", "sessione BiDi chiusa"), riavvia Firefox+bridge e riprova una
* volta. Evita di lasciare l'utente con errori criptici dopo un crash.
*/
async function withSessionRecovery<T>(fn: () => Promise<T>, label: string): Promise<T> {
try {
return await fn();
} catch (err) {
if (!isSessionError(err)) throw err;
log(`${label}: sessione BiDi non valida (${(err as Error).message}), riavvio Firefox+bridge`);
await ensureBridge("dedicated", { forceRestart: true });
return await fn();
}
}
export default function (pi: ExtensionAPI) {
// -------------------------------------------------------------------------
// Ciclo di vita del browser
@@ -253,12 +305,20 @@ export default function (pi: ExtensionAPI) {
}),
async execute(_id, params) {
await ensureBridge();
const r = await withRetry(
const r = await withSessionRecovery(
() =>
bridge("/navigate", {
method: "POST",
body: JSON.stringify({ url: params.url }),
}),
withRetry(
() =>
bridge(
"/navigate",
{
method: "POST",
body: JSON.stringify({ url: params.url }),
},
DEFAULT_TIMEOUT_MS,
),
"navigate",
),
"navigate",
);
return {
@@ -282,12 +342,20 @@ export default function (pi: ExtensionAPI) {
}),
async execute(_id, params) {
await ensureBridge();
const r = await withRetry(
const r = await withSessionRecovery(
() =>
bridge("/screenshot", {
method: "POST",
body: JSON.stringify({ out: params.path, fullPage: params.fullPage ?? false }),
}),
withRetry(
() =>
bridge(
"/screenshot",
{
method: "POST",
body: JSON.stringify({ out: params.path, fullPage: params.fullPage ?? false }),
},
SCREENSHOT_TIMEOUT_MS,
),
"screenshot",
),
"screenshot",
);
return {
@@ -311,12 +379,20 @@ export default function (pi: ExtensionAPI) {
}),
async execute(_id, params) {
await ensureBridge();
const r = await withRetry(
const r = await withSessionRecovery(
() =>
bridge("/eval", {
method: "POST",
body: JSON.stringify(params),
}),
withRetry(
() =>
bridge(
"/eval",
{
method: "POST",
body: JSON.stringify(params),
},
EVAL_TIMEOUT_MS,
),
"eval",
),
"eval",
);
return {
@@ -338,10 +414,18 @@ export default function (pi: ExtensionAPI) {
}),
async execute(_id, params) {
await ensureBridge();
const r = await bridge("/cmd", {
method: "POST",
body: JSON.stringify(params),
});
const r = await withSessionRecovery(
() =>
bridge(
"/cmd",
{
method: "POST",
body: JSON.stringify(params),
},
SEND_TIMEOUT_MS,
),
"browser_send",
);
return {
content: [{ type: "text", text: JSON.stringify(r, null, 2) }],
details: r,
@@ -370,10 +454,14 @@ export default function (pi: ExtensionAPI) {
}),
async execute(_id, params) {
await ensureBridge();
const r = await bridge("/events", {
method: "POST",
body: JSON.stringify(params),
});
const r = await bridge(
"/events",
{
method: "POST",
body: JSON.stringify(params),
},
EVENTS_TIMEOUT_MS,
);
return {
content: [{ type: "text", text: JSON.stringify(r, null, 2) }],
details: r,