From 92870d4ab9f9839ba145d0156c10a9781381cdf1 Mon Sep 17 00:00:00 2001 From: Altay Date: Tue, 10 Mar 2026 23:36:36 +0300 Subject: [PATCH] fix(browser): scope 429 hints to Browserbase URLs --- src/browser/cdp.helpers.ts | 6 +-- .../client-fetch.loopback-auth.test.ts | 41 +++++++++++++++---- src/browser/client-fetch.ts | 32 +++++++++++++-- 3 files changed, 65 insertions(+), 14 deletions(-) diff --git a/src/browser/cdp.helpers.ts b/src/browser/cdp.helpers.ts index 890c49bed67..44f689e8706 100644 --- a/src/browser/cdp.helpers.ts +++ b/src/browser/cdp.helpers.ts @@ -3,7 +3,7 @@ import { isLoopbackHost } from "../gateway/net.js"; import { rawDataToString } from "../infra/ws.js"; import { getDirectAgentForCdp, withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js"; import { CDP_HTTP_REQUEST_TIMEOUT_MS, CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js"; -import { BROWSER_RATE_LIMIT_MESSAGE } from "./client-fetch.js"; +import { resolveBrowserRateLimitMessage } from "./client-fetch.js"; import { getChromeExtensionRelayAuthHeaders } from "./extension-relay.js"; export { isLoopbackHost }; @@ -175,9 +175,7 @@ export async function fetchCdpChecked( if (!res.ok) { if (res.status === 429) { // Do not reflect upstream response text into the error surface (log/agent injection risk) - throw new Error( - `${BROWSER_RATE_LIMIT_MESSAGE} Do NOT retry - wait for the current session to complete, or upgrade your plan.`, - ); + throw new Error(`${resolveBrowserRateLimitMessage(url)} Do NOT retry the browser tool.`); } throw new Error(`HTTP ${res.status}`); } diff --git a/src/browser/client-fetch.loopback-auth.test.ts b/src/browser/client-fetch.loopback-auth.test.ts index 34226d9c656..4b9aec88acc 100644 --- a/src/browser/client-fetch.loopback-auth.test.ts +++ b/src/browser/client-fetch.loopback-auth.test.ts @@ -1,4 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { BrowserDispatchResponse } from "./routes/dispatcher.js"; + +function okDispatchResponse(): BrowserDispatchResponse { + return { status: 200, body: { ok: true } }; +} const mocks = vi.hoisted(() => ({ loadConfig: vi.fn(() => ({ @@ -9,7 +14,7 @@ const mocks = vi.hoisted(() => ({ }, })), startBrowserControlServiceFromConfig: vi.fn(async () => ({ ok: true })), - dispatch: vi.fn(async () => ({ status: 200, body: { ok: true } })), + dispatch: vi.fn(async (): Promise => okDispatchResponse()), })); vi.mock("../config/config.js", async (importOriginal) => { @@ -57,7 +62,7 @@ describe("fetchBrowserJson loopback auth", () => { }, }); mocks.startBrowserControlServiceFromConfig.mockReset().mockResolvedValue({ ok: true }); - mocks.dispatch.mockReset().mockResolvedValue({ status: 200, body: { ok: true } }); + mocks.dispatch.mockReset().mockResolvedValue(okDispatchResponse()); }); afterEach(() => { @@ -147,13 +152,16 @@ describe("fetchBrowserJson loopback auth", () => { if (!(thrown instanceof Error)) { throw new Error(`Expected Error, got ${String(thrown)}`); } - expect(thrown.message).toContain("rate limit reached"); + expect(thrown.message).toContain("Browser service rate limit reached"); expect(thrown.message).toContain("Do NOT retry the browser tool"); - expect(thrown.message).toContain("max concurrent sessions exceeded"); + expect(thrown.message).not.toContain("max concurrent sessions exceeded"); }); it("surfaces 429 from HTTP URL without body detail when empty", async () => { - vi.stubGlobal("fetch", vi.fn(async () => new Response("", { status: 429 }))); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("", { status: 429 })), + ); const thrown = await fetchBrowserJson<{ ok: boolean }>("http://127.0.0.1:18888/").catch( (err: unknown) => err, @@ -167,6 +175,25 @@ describe("fetchBrowserJson loopback auth", () => { expect(thrown.message).toContain("Do NOT retry the browser tool"); }); + it("keeps Browserbase-specific wording for Browserbase 429 responses", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("max concurrent sessions exceeded", { status: 429 })), + ); + + const thrown = await fetchBrowserJson<{ ok: boolean }>( + "https://connect.browserbase.com/session", + ).catch((err: unknown) => err); + + expect(thrown).toBeInstanceOf(Error); + if (!(thrown instanceof Error)) { + throw new Error(`Expected Error, got ${String(thrown)}`); + } + expect(thrown.message).toContain("Browserbase rate limit reached"); + expect(thrown.message).toContain("upgrade your plan"); + expect(thrown.message).not.toContain("max concurrent sessions exceeded"); + }); + it("non-429 errors still produce generic messages", async () => { vi.stubGlobal( "fetch", @@ -197,9 +224,9 @@ describe("fetchBrowserJson loopback auth", () => { if (!(thrown instanceof Error)) { throw new Error(`Expected Error, got ${String(thrown)}`); } - expect(thrown.message).toContain("rate limit reached"); + expect(thrown.message).toContain("Browser service rate limit reached"); expect(thrown.message).toContain("Do NOT retry the browser tool"); - expect(thrown.message).toContain("too many sessions"); + expect(thrown.message).not.toContain("too many sessions"); }); it("keeps absolute URL failures wrapped as reachability errors", async () => { diff --git a/src/browser/client-fetch.ts b/src/browser/client-fetch.ts index f3c51f80bfc..efaebd68cfe 100644 --- a/src/browser/client-fetch.ts +++ b/src/browser/client-fetch.ts @@ -102,7 +102,11 @@ const BROWSER_TOOL_MODEL_HINT = "Do NOT retry the browser tool — it will keep failing. " + "Use an alternative approach or inform the user that the browser is currently unavailable."; -export const BROWSER_RATE_LIMIT_MESSAGE = +const BROWSER_SERVICE_RATE_LIMIT_MESSAGE = + "Browser service rate limit reached. " + + "Wait for the current session to complete, or retry later."; + +const BROWSERBASE_RATE_LIMIT_MESSAGE = "Browserbase rate limit reached (max concurrent sessions). " + "Wait for the current session to complete, or upgrade your plan."; @@ -110,6 +114,24 @@ function isRateLimitStatus(status: number): boolean { return status === 429; } +function isBrowserbaseUrl(url: string): boolean { + if (!isAbsoluteHttp(url)) { + return false; + } + try { + const host = new URL(url).hostname.toLowerCase(); + return host === "browserbase.com" || host.endsWith(".browserbase.com"); + } catch { + return false; + } +} + +export function resolveBrowserRateLimitMessage(url: string): string { + return isBrowserbaseUrl(url) + ? BROWSERBASE_RATE_LIMIT_MESSAGE + : BROWSER_SERVICE_RATE_LIMIT_MESSAGE; +} + function resolveBrowserFetchOperatorHint(url: string): string { const isLocal = !isAbsoluteHttp(url); return isLocal @@ -186,7 +208,9 @@ async function fetchHttpJson( const text = await res.text().catch(() => ""); if (isRateLimitStatus(res.status)) { // Do not reflect upstream response text into the error surface (log/agent injection risk) - throw new BrowserServiceError(`${BROWSER_RATE_LIMIT_MESSAGE} ${BROWSER_TOOL_MODEL_HINT}`); + throw new BrowserServiceError( + `${resolveBrowserRateLimitMessage(url)} ${BROWSER_TOOL_MODEL_HINT}`, + ); } throw new BrowserServiceError(text || `HTTP ${res.status}`); } @@ -283,7 +307,9 @@ export async function fetchBrowserJson( if (result.status >= 400) { if (isRateLimitStatus(result.status)) { // Do not reflect upstream response text into the error surface (log/agent injection risk) - throw new BrowserServiceError(`${BROWSER_RATE_LIMIT_MESSAGE} ${BROWSER_TOOL_MODEL_HINT}`); + throw new BrowserServiceError( + `${resolveBrowserRateLimitMessage(url)} ${BROWSER_TOOL_MODEL_HINT}`, + ); } const message = result.body && typeof result.body === "object" && "error" in result.body