diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b435ce2a80..ab14e0fefef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,7 @@ Docs: https://docs.openclaw.ai - Models/Alibaba Cloud Model Studio: wire `MODELSTUDIO_API_KEY` through shared env auth, implicit provider discovery, and shell-env fallback so onboarding works outside the wizard too. (#40634) Thanks @pomelo-nwu. - ACP/sessions_spawn: implicitly stream `mode="run"` ACP spawns to parent only for eligible subagent orchestrator sessions (heartbeat `target: "last"` with a usable session-local route), restoring parent progress relays without thread binding. (#42404) Thanks @davidguttman. - Sessions/reset model recompute: clear stale runtime model, context-token, and system-prompt metadata before session resets recompute the replacement session, so resets pick up current defaults and explicit overrides instead of reusing old runtime model state. (#41173) thanks @PonyX-lab. +- Browser/Browserbase 429 handling: surface stable no-retry rate-limit guidance without buffering discarded HTTP 429 response bodies from remote browser services. (#40491) thanks @mvanhorn. ## 2026.3.8 diff --git a/src/browser/client-fetch.loopback-auth.test.ts b/src/browser/client-fetch.loopback-auth.test.ts index 4b9aec88acc..1fe4e3b5c02 100644 --- a/src/browser/client-fetch.loopback-auth.test.ts +++ b/src/browser/client-fetch.loopback-auth.test.ts @@ -139,9 +139,11 @@ describe("fetchBrowserJson loopback auth", () => { }); it("surfaces 429 from HTTP URL as rate-limit error with no-retry hint", async () => { + const text = vi.fn(async () => "max concurrent sessions exceeded"); + const cancel = vi.fn(async () => {}); vi.stubGlobal( "fetch", - vi.fn(async () => new Response("max concurrent sessions exceeded", { status: 429 })), + vi.fn(async () => ({ ok: false, status: 429, text, body: { cancel } }) as Response), ); const thrown = await fetchBrowserJson<{ ok: boolean }>("http://127.0.0.1:18888/").catch( @@ -155,6 +157,8 @@ describe("fetchBrowserJson loopback auth", () => { expect(thrown.message).toContain("Browser service rate limit reached"); expect(thrown.message).toContain("Do NOT retry the browser tool"); expect(thrown.message).not.toContain("max concurrent sessions exceeded"); + expect(text).not.toHaveBeenCalled(); + expect(cancel).toHaveBeenCalledOnce(); }); it("surfaces 429 from HTTP URL without body detail when empty", async () => { diff --git a/src/browser/client-fetch.ts b/src/browser/client-fetch.ts index efaebd68cfe..e321c5a1e62 100644 --- a/src/browser/client-fetch.ts +++ b/src/browser/client-fetch.ts @@ -153,6 +153,14 @@ function appendBrowserToolModelHint(message: string): string { return `${message} ${BROWSER_TOOL_MODEL_HINT}`; } +async function discardResponseBody(res: Response): Promise { + try { + await res.body?.cancel(); + } catch { + // Best effort only; we're already returning a stable error message. + } +} + function enhanceDispatcherPathError(url: string, err: unknown): Error { const msg = normalizeErrorMessage(err); const suffix = `${resolveBrowserFetchOperatorHint(url)} ${BROWSER_TOOL_MODEL_HINT}`; @@ -205,13 +213,14 @@ async function fetchHttpJson( try { const res = await fetch(url, { ...init, signal: ctrl.signal }); if (!res.ok) { - const text = await res.text().catch(() => ""); if (isRateLimitStatus(res.status)) { // Do not reflect upstream response text into the error surface (log/agent injection risk) + await discardResponseBody(res); throw new BrowserServiceError( `${resolveBrowserRateLimitMessage(url)} ${BROWSER_TOOL_MODEL_HINT}`, ); } + const text = await res.text().catch(() => ""); throw new BrowserServiceError(text || `HTTP ${res.status}`); } return (await res.json()) as T;