mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:50:43 +00:00
fix(browser): keep transient fetch errors retryable
Co-authored-by: jriff <jriff@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user