fix(browser): scope 429 hints to Browserbase URLs

This commit is contained in:
Altay
2026-03-10 23:36:36 +03:00
parent 2c21381d0f
commit 92870d4ab9
3 changed files with 65 additions and 14 deletions

View File

@@ -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}`);
}

View File

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

View File

@@ -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<T>(
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<T>(
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