For AI agents: a documentation index is available at /llms.txt
Skip to main content

Error handling in BAP

BAP raises typed errors, so a dead WebSocket, a slow page, and a failed BrowserQL mutation each surface as a different class. This guide shows how to catch and branch on each type, tune the timeouts that produce them, and read the GraphQL errors BrowserQL returns.

Prerequisites

The error types

Each failure gets its own class, so an instanceof check in TypeScript or an except clause in Python tells you which stage failed. Python's errors also share the BAPError base for catch-all handling.

ErrorThrown when
ConnectionErrorThe WebSocket fails to open, drops mid-session, or is unavailable
TimeoutErrorAn operation exceeds its transport deadline
BrowserQLErrorThe BrowserQL server returns GraphQL errors. The errors property holds the raw GraphQL error objects

All three are exported from @browserless.io/bap-ts alongside the default export.

Catch and branch on error type

Branching on type tells you what to do next: reconnect on ConnectionError, raise the deadline or fix the selector on TimeoutError, and read the server's message on BrowserQLError.

import Browserless, {
BrowserQLError,
ConnectionError,
TimeoutError,
} from "@browserless.io/bap-ts";

const TOKEN = "YOUR_API_TOKEN_HERE";

const browser = Browserless.connect({
browserWSEndpoint: "wss://production-sfo.browserless.io/chromium/bql",
token: TOKEN,
});

try {
const page = await browser.newPage();
await page.goto("https://example.com", { timeout: 5000 });
} catch (error) {
if (error instanceof TimeoutError) {
console.error("operation timed out");
} else if (error instanceof ConnectionError) {
console.error("websocket connection failed");
} else if (error instanceof BrowserQLError) {
// The raw GraphQL error objects from the server.
console.error("browserql returned errors:", error.errors);
}
} finally {
await browser.close();
}

Response on a timeout:

operation timed out

Timeouts

Every operation runs against a deadline of 30000 milliseconds unless you change it. There are three places to do that, and the more specific one wins:

  1. Per connection. The timeout option on Browserless.connect() replaces the default for every operation on that browser.
  2. Per session. preferences({ timeout }) replaces the session's defaults from inside the page, which is the option to reach for when you're driving a session you didn't open, such as one you reconnected to.
  3. Per call. Navigation, selector, and CAPTCHA methods accept their own timeout option, which overrides both of the above for that one call.
// Every operation on this browser gets 60 seconds.
const browser = Browserless.connect({
browserWSEndpoint: "wss://production-sfo.browserless.io/chromium/bql",
token: TOKEN,
timeout: 60_000,
});

const page = await browser.newPage();

// This one call gets 10 seconds, overriding the 60 above.
await page.waitForSelector(".results", { timeout: 10_000 });

await browser.close();

Set the connection-level timeout to the slowest operation you consider healthy, then lower it per call where you want to fail fast. A short deadline on waitForSelector catches a missing element in seconds instead of holding the session for the full default.

How BrowserQL failures surface

BAP is a typed wrapper over BrowserQL, and BrowserQL reports failures in a GraphQL errors array rather than through HTTP status codes. When a mutation your method call maps to comes back with errors, the SDK raises BrowserQLError carrying those raw error objects. The message text is written by the BrowserQL server, so it names the mutation and the reason, such as a selector that never matched or a navigation that was blocked.

Two consequences of the GraphQL model are worth knowing before you debug:

  • Navigation doesn't throw on HTTP error statuses. goto() resolves with the response object, so a 404 or 500 from the target site is data, not an exception. Check response.status when you need to react to it.
  • The server decides what's fatal. Some conditions BrowserQL reports as errors are recoverable from the session's point of view. The BQL error handling guide covers how the underlying protocol behaves, which is what BrowserQLError is wrapping.
const response = await page.goto("https://example.com/missing-page");

// A 404 arrives here, not in a catch block.
if (response && response.status >= 400) {
console.warn(`target returned ${response.status}`);
}

Retrying failed operations

ConnectionError is the type worth retrying, because a dropped socket says nothing about your script's logic. Reconnect by creating a fresh page rather than reusing the dead one, since each page owns its own WebSocket. TimeoutError and BrowserQLError usually mean the page or the script needs to change, so blind retries tend to fail the same way while spending your concurrency.

One constraint on what you retry: the server may have executed a mutation even though its response never reached you, so a retried callback can repeat a click or a form submission. Pass only operations that are safe to run twice, such as navigate-and-read, and reconcile anything non-idempotent explicitly before retrying it.

import Browserless, { ConnectionError } from "@browserless.io/bap-ts";

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

async function withRetry<T>(run: () => Promise<T>, attempts = 3): Promise<T> {
for (let attempt = 1; ; attempt++) {
try {
return await run();
} catch (error) {
// Only a dropped socket is worth retrying; other errors repeat identically.
if (!(error instanceof ConnectionError) || attempt === attempts) throw error;
// Back off between attempts so retries don't hammer a busy service.
await sleep(1000 * 2 ** (attempt - 1));
}
}
}

const title = await withRetry(async () => {
const page = await browser.newPage();
try {
await page.goto("https://example.com");
return await page.title();
} finally {
// Every page holds its own socket and concurrency slot, so close it
// whether the attempt succeeded or not.
await page.close();
}
});

The retry with exponential backoff example expands this pattern with jitter and queue-aware handling. If the session itself must survive a client-side crash or restart, reconnect to the running session instead of starting over.

FAQ & Troubleshooting

Why did my operation time out after exactly 30 seconds?

30000 milliseconds is the default per-operation timeout. Raise it for the whole browser with timeout on Browserless.connect(), or per call with the timeout option on navigation, selector, and CAPTCHA methods. The per-call value wins when both are set.

(Python) My except ConnectionError block never fires

You're catching Python's builtin ConnectionError, not the SDK's. Add from bap import ConnectionError to the module doing the catching. The same applies to TimeoutError.

goto() didn't throw even though the page returned a 500

That's by design. BrowserQL reports HTTP status through the response object rather than as an error, so a failing target site is data you inspect with response.status. Only transport failures, timeouts, and GraphQL errors throw.

What's inside a BrowserQLError?

The raw GraphQL error objects the BrowserQL server returned, on the errors property in TypeScript and in the exception message in Python. Each one names the mutation that failed and why. The BQL error handling guide explains the underlying error format.

Next steps

Was this page helpful?