fix(browser): keep transient fetch errors retryable

Co-authored-by: jriff <jriff@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-04-25 10:06:02 +01:00
parent 2483d1dc12
commit f6a3b42cfa
4 changed files with 95 additions and 17 deletions

View File

@@ -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.

View File

@@ -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"],
},
);
});
});

View File

@@ -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<void> {
try {
await res.body?.cancel();
@@ -137,32 +158,36 @@ async function discardResponseBody(res: Response): Promise<void> {
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,
);
}

View File

@@ -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 () => {