diff --git a/CHANGELOG.md b/CHANGELOG.md index 6250e909dcb..f1ca541b09d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Browser/Playwright: ignore benign already-handled route races during guarded navigation so browser-page tasks no longer fail when Playwright tears down a route mid-flight. (#68708) Thanks @Steady-ai. - Browser/downloads: seed managed Chrome profiles with OpenClaw download prefs and capture unmanaged click-triggered downloads under the guarded downloads directory, while explicit download waiters still own their target file. (#64558) Thanks @Pearcekieser. - Browser/Chrome: stop passing redundant `--disable-setuid-sandbox` when `browser.noSandbox` is enabled; `--no-sandbox` remains the effective sandbox opt-out. (#67939) Thanks @sebykrueger. +- Browser/client: stop telling agents to permanently avoid the browser after transient timeout or cancellation failures; keep the no-retry hint for persistent unavailable/rate-limit cases. (#46505) Thanks @jriff. - Browser/aria snapshots: bind `format=aria` `axN` refs to live DOM nodes through backend DOM ids when Playwright is available, so follow-up browser actions can use those refs without timing out. (#62434) Thanks @MrKipler. - Telegram: prevent duplicate in-process long pollers for the same bot token and add clearer `getUpdates` conflict diagnostics for external duplicate pollers. Fixes #56230. - Browser/Linux: detect Chromium-based installs under `/opt/google`, `/opt/brave.com`, `/usr/lib/chromium`, and `/usr/lib/chromium-browser` before asking users to set `browser.executablePath`. (#48563) Thanks @lupuletic. diff --git a/extensions/browser/src/browser/client-fetch.loopback-auth.test.ts b/extensions/browser/src/browser/client-fetch.loopback-auth.test.ts index 1940a2ff07c..4b8f1f3e99c 100644 --- a/extensions/browser/src/browser/client-fetch.loopback-auth.test.ts +++ b/extensions/browser/src/browser/client-fetch.loopback-auth.test.ts @@ -197,11 +197,29 @@ describe("fetchBrowserJson loopback auth", () => { expect(headers.get("authorization")).toBe("Bearer loopback-token"); }); - it("preserves dispatcher error context while keeping no-retry hint", async () => { + it("preserves dispatcher timeout context without no-retry hint", async () => { mocks.dispatch.mockRejectedValueOnce(new Error("Chrome CDP handshake timeout")); await expectThrownBrowserFetchError(() => fetchBrowserJson<{ ok: boolean }>("/tabs"), { - contains: ["Chrome CDP handshake timeout", "Do NOT retry the browser tool"], + contains: ["Chrome CDP handshake timeout", "Restart the OpenClaw gateway"], + omits: ["Can't reach the OpenClaw browser control service", "Do NOT retry the browser tool"], + }); + }); + + it("preserves dispatcher abort context without no-retry hint", async () => { + mocks.dispatch.mockRejectedValueOnce(new DOMException("operation aborted", "AbortError")); + + await expectThrownBrowserFetchError(() => fetchBrowserJson<{ ok: boolean }>("/tabs"), { + contains: ["operation aborted", "Restart the OpenClaw gateway"], + omits: ["Do NOT retry the browser tool"], + }); + }); + + it("keeps no-retry hint for persistent dispatcher failures", async () => { + mocks.dispatch.mockRejectedValueOnce(new Error("Chrome CDP connection refused")); + + await expectThrownBrowserFetchError(() => fetchBrowserJson<{ ok: boolean }>("/tabs"), { + contains: ["Chrome CDP connection refused", "Do NOT retry the browser tool"], omits: ["Can't reach the OpenClaw browser control service"], }); }); @@ -300,4 +318,38 @@ describe("fetchBrowserJson loopback auth", () => { }, ); }); + + it("omits no-retry hint for absolute HTTP timeout failures", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("timed out"); + }), + ); + + await expectThrownBrowserFetchError( + () => fetchBrowserJson<{ ok: boolean }>("http://example.com/", { timeoutMs: 1234 }), + { + contains: ["timed out after 1234ms"], + omits: ["Do NOT retry the browser tool"], + }, + ); + }); + + it("omits no-retry hint for absolute HTTP abort failures", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new DOMException("operation aborted", "AbortError"); + }), + ); + + await expectThrownBrowserFetchError( + () => fetchBrowserJson<{ ok: boolean }>("http://example.com/"), + { + contains: ["Browser control request was cancelled"], + omits: ["Do NOT retry the browser tool"], + }, + ); + }); }); diff --git a/extensions/browser/src/browser/client-fetch.ts b/extensions/browser/src/browser/client-fetch.ts index bd6bcf0b6c6..e20d8e72d79 100644 --- a/extensions/browser/src/browser/client-fetch.ts +++ b/extensions/browser/src/browser/client-fetch.ts @@ -127,6 +127,27 @@ function appendBrowserToolModelHint(message: string): string { return `${message} ${BROWSER_TOOL_MODEL_HINT}`; } +type BrowserFetchFailureKind = "timeout" | "aborted" | "persistent"; + +function classifyBrowserFetchFailure(err: unknown): BrowserFetchFailureKind { + const msg = normalizeErrorMessage(err); + const msgLower = normalizeLowercaseStringOrEmpty(msg); + const nameLower = err instanceof Error ? normalizeLowercaseStringOrEmpty(err.name) : ""; + const looksLikeTimeout = + nameLower.includes("timeout") || msgLower.includes("timed out") || msgLower.includes("timeout"); + if (looksLikeTimeout) { + return "timeout"; + } + const looksLikeAbort = + nameLower === "aborterror" || + msgLower.includes("aborterror") || + msgLower.includes("aborted") || + msgLower.includes("abort") || + msgLower.includes("cancelled") || + msgLower.includes("canceled"); + return looksLikeAbort ? "aborted" : "persistent"; +} + async function discardResponseBody(res: Response): Promise { try { await res.body?.cancel(); @@ -137,32 +158,36 @@ async function discardResponseBody(res: Response): Promise { function enhanceDispatcherPathError(url: string, err: unknown): Error { const msg = normalizeErrorMessage(err); - const suffix = `${resolveBrowserFetchOperatorHint(url)} ${BROWSER_TOOL_MODEL_HINT}`; + const kind = classifyBrowserFetchFailure(err); + const suffix = + kind === "persistent" + ? `${resolveBrowserFetchOperatorHint(url)} ${BROWSER_TOOL_MODEL_HINT}` + : resolveBrowserFetchOperatorHint(url); const normalized = msg.endsWith(".") ? msg : `${msg}.`; return new Error(`${normalized} ${suffix}`, err instanceof Error ? { cause: err } : undefined); } function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number): Error { const operatorHint = resolveBrowserFetchOperatorHint(url); - const msg = String(err); - const msgLower = normalizeLowercaseStringOrEmpty(msg); - const looksLikeTimeout = - msgLower.includes("timed out") || - msgLower.includes("timeout") || - msgLower.includes("aborted") || - msgLower.includes("abort") || - msgLower.includes("aborterror"); - if (looksLikeTimeout) { + const msg = normalizeErrorMessage(err); + const kind = classifyBrowserFetchFailure(err); + if (kind === "timeout") { return new Error( - appendBrowserToolModelHint( - `Can't reach the OpenClaw browser control service (timed out after ${timeoutMs}ms). ${operatorHint}`, - ), + `Can't reach the OpenClaw browser control service (timed out after ${timeoutMs}ms). ${operatorHint}`, + err instanceof Error ? { cause: err } : undefined, + ); + } + if (kind === "aborted") { + return new Error( + `Browser control request was cancelled. ${operatorHint}`, + err instanceof Error ? { cause: err } : undefined, ); } return new Error( appendBrowserToolModelHint( `Can't reach the OpenClaw browser control service. ${operatorHint} (${msg})`, ), + err instanceof Error ? { cause: err } : undefined, ); } diff --git a/extensions/browser/src/browser/client.test.ts b/extensions/browser/src/browser/client.test.ts index cdb608d9df6..fddd91be099 100644 --- a/extensions/browser/src/browser/client.test.ts +++ b/extensions/browser/src/browser/client.test.ts @@ -53,9 +53,9 @@ describe("browser client", () => { await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/sandboxed session/i); }); - it("adds useful timeout messaging for abort-like failures", async () => { + it("adds useful cancellation messaging for abort-like failures", async () => { vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("aborted"))); - await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/timed out/i); + await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/cancelled/i); }); it("surfaces non-2xx responses with body text", async () => {