v0.3: fail-fast timeout, retry con jitter, recupero sessione, messaggi di errore chiari
This commit is contained in:
+127
-39
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user