diff --git a/.changeset/driver-error-remediation.md b/.changeset/driver-error-remediation.md new file mode 100644 index 000000000..064e01556 --- /dev/null +++ b/.changeset/driver-error-remediation.md @@ -0,0 +1,10 @@ +--- +"browse": patch +--- + +Make driver (browser session) failures actionable, classified, and self-correcting. + +- An invalid `BROWSERBASE_API_KEY` no longer surfaces a bare `401 Unauthorized`: remote init failures are classified (401 invalid key, 403 permissions/plan, other) into actionable messages that point at the key settings page, `--local`, and `browse doctor`. +- A missing local Chrome now explains how to install Chrome, attach with `--cdp`, or switch to remote, instead of leaking chrome-launcher internals. +- Cached init failures back off exponentially (5s doubling, capped at 5 minutes) and append a "failing repeatedly" hint after 3 consecutive failures, so retry-looping agents get a clear self-correction signal instead of instant identical errors forever. +- The daemon protocol now carries optional `code`/`httpStatus` on error responses (backward compatible), and the client records them as telemetry result codes — `open` failures stop being 94% `unexpected`. New codes include `remote_auth_401`, `remote_auth_403`, `remote_session_create_failed`, `no_chrome_found`, `stale_ref`, `no_active_page`, `daemon_lock_timeout`, `daemon_unresponsive`, `daemon_socket_timeout`, and `daemon_spawn_failed`. diff --git a/packages/cli/package.json b/packages/cli/package.json index b3c7871ea..fcbb49490 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -109,6 +109,7 @@ "deepmerge": "^4.3.1", "dotenv": "^16.5.0", "fastest-levenshtein": "^1.0.16", + "http-status-codes": "^2.3.0", "ignore": "^7.0.5", "node-html-markdown": "^1.3.0", "semver": "^7.7.4", diff --git a/packages/cli/src/lib/driver/commands/selectors.ts b/packages/cli/src/lib/driver/commands/selectors.ts index 9b0f68243..283e11828 100644 --- a/packages/cli/src/lib/driver/commands/selectors.ts +++ b/packages/cli/src/lib/driver/commands/selectors.ts @@ -1,3 +1,5 @@ +import { DriverError } from "../errors.js"; + export interface RefMaps { urlMap: Record; xpathMap: Record; @@ -36,8 +38,9 @@ export function resolveSelector(selector: string, refMaps: RefMaps): string { const xpath = refMaps.xpathMap[ref]; if (!xpath) { - throw new Error( + throw new DriverError( `Unknown ref "${ref}" - run browse snapshot first to populate refs (have ${Object.keys(refMaps.xpathMap).length} refs).`, + { code: "stale_ref" }, ); } diff --git a/packages/cli/src/lib/driver/daemon/client.ts b/packages/cli/src/lib/driver/daemon/client.ts index 353e05bc4..9f1aa8925 100644 --- a/packages/cli/src/lib/driver/daemon/client.ts +++ b/packages/cli/src/lib/driver/daemon/client.ts @@ -2,7 +2,7 @@ import { spawn } from "node:child_process"; import { promises as fs } from "node:fs"; import net from "node:net"; -import { fail } from "../../errors.js"; +import { CommandFailure, fail } from "../../errors.js"; import type { DriverCommandName } from "../commands/types.js"; import { targetsCompatible } from "../mode.js"; import type { ConnectionTarget, DriverStatus, OpenResult } from "../types.js"; @@ -40,7 +40,11 @@ export async function ensureDriverDaemon({ const locked = await acquireLock(session); if (!locked) { - fail(`Timed out waiting for driver daemon lock for session "${session}".`); + fail( + `Timed out waiting for driver daemon lock for session "${session}".`, + 1, + { resultCode: "daemon_lock_timeout" }, + ); } try { @@ -52,6 +56,8 @@ export async function ensureDriverDaemon({ if (await isDaemonPidAlive(session)) { fail( `Driver daemon session "${session}" is running but not responding. Run browse stop --session ${session} --force to clean it up.`, + 1, + { resultCode: "daemon_unresponsive" }, ); } spawnDaemon(session, target); @@ -180,7 +186,11 @@ async function sendDriverRequest( const timeout = setTimeout(() => { failRequest( - new Error(`Timed out waiting for driver daemon session "${session}".`), + new CommandFailure( + `Timed out waiting for driver daemon session "${session}".`, + 1, + { resultCode: "daemon_socket_timeout" }, + ), ); }, 35_000); @@ -196,7 +206,14 @@ async function sendDriverRequest( JSON.parse(buffer.slice(0, newline)), ); if (response.type === "error") { - failRequest(new Error(response.error)); + failRequest( + new CommandFailure(response.error, 1, { + ...(response.code ? { resultCode: response.code } : {}), + ...(response.httpStatus !== undefined + ? { httpStatus: response.httpStatus } + : {}), + }), + ); return; } completeRequest(response.data as T); @@ -219,7 +236,9 @@ async function sendDriverRequest( function spawnDaemon(session: string, target: ConnectionTarget): void { const entrypoint = process.argv[1]; if (!entrypoint) { - fail("Unable to locate browse CLI entrypoint for daemon startup."); + fail("Unable to locate browse CLI entrypoint for daemon startup.", 1, { + resultCode: "daemon_spawn_failed", + }); } const child = spawn( diff --git a/packages/cli/src/lib/driver/daemon/protocol.ts b/packages/cli/src/lib/driver/daemon/protocol.ts index f546cffeb..3dc3ef925 100644 --- a/packages/cli/src/lib/driver/daemon/protocol.ts +++ b/packages/cli/src/lib/driver/daemon/protocol.ts @@ -41,7 +41,9 @@ export const SuccessResponseSchema = z.object({ }); export const ErrorResponseSchema = z.object({ + code: z.string().optional(), error: z.string(), + httpStatus: z.number().optional(), id: z.string().optional(), type: z.literal("error"), }); diff --git a/packages/cli/src/lib/driver/daemon/server.ts b/packages/cli/src/lib/driver/daemon/server.ts index 6f16d5465..e77118923 100644 --- a/packages/cli/src/lib/driver/daemon/server.ts +++ b/packages/cli/src/lib/driver/daemon/server.ts @@ -1,6 +1,7 @@ import net from "node:net"; import readline from "node:readline"; +import { DriverError } from "../errors.js"; import { DriverSessionManager } from "../session-manager.js"; import type { ConnectionTarget } from "../types.js"; import { @@ -125,7 +126,7 @@ async function handleLine( try { request = parseRequest(line); } catch (error) { - await writeResponse(socket, { error: formatError(error), type: "error" }); + await writeResponse(socket, { ...formatError(error), type: "error" }); return false; } @@ -169,7 +170,7 @@ async function handleLine( return true; } catch (error) { await writeResponse(socket, { - error: formatError(error), + ...formatError(error), id: request.id, type: "error", }); @@ -212,6 +213,20 @@ function endSocket(socket: net.Socket): Promise { }); } -function formatError(error: unknown): string { - return error instanceof Error ? error.message : String(error); +function formatError(error: unknown): { + code?: string; + error: string; + httpStatus?: number; +} { + const message = error instanceof Error ? error.message : String(error); + if (error instanceof DriverError) { + return { + code: error.code, + error: message, + ...(error.httpStatus !== undefined + ? { httpStatus: error.httpStatus } + : {}), + }; + } + return { error: message }; } diff --git a/packages/cli/src/lib/driver/errors.ts b/packages/cli/src/lib/driver/errors.ts new file mode 100644 index 000000000..8c5c20c8a --- /dev/null +++ b/packages/cli/src/lib/driver/errors.ts @@ -0,0 +1,19 @@ +/** + * Typed driver error. The daemon serializes `code`/`httpStatus` into error + * responses so the client can record a telemetry result code and agents get + * a stable, machine-readable failure reason alongside the human message. + */ +export class DriverError extends Error { + readonly code: string; + readonly httpStatus?: number; + + constructor( + message: string, + options: { cause?: unknown; code: string; httpStatus?: number }, + ) { + super(message, options.cause === undefined ? {} : { cause: options.cause }); + this.name = "DriverError"; + this.code = options.code; + if (options.httpStatus !== undefined) this.httpStatus = options.httpStatus; + } +} diff --git a/packages/cli/src/lib/driver/remote-types.ts b/packages/cli/src/lib/driver/remote-types.ts index 653f05df1..e19aa5b33 100644 --- a/packages/cli/src/lib/driver/remote-types.ts +++ b/packages/cli/src/lib/driver/remote-types.ts @@ -13,6 +13,24 @@ export interface RemoteDoctorResult { fix?: string; } +export interface RemoteInitErrorClassification { + code: string; + httpStatus?: number; + message: string; +} + +/** + * Driver init remediation strings that may reference `BROWSERBASE_API_KEY`. + * They live behind the remote capability so the local-only artifact contains + * key-free variants (its build excludes `remote.ts` entirely). + */ +export interface DriverInitHints { + /** Actionable message when no local Chrome can be found. */ + chromeNotFound: string; + /** Suffix appended after repeated consecutive init failures. */ + repeatedInitFailure: string; +} + /** * The Browserbase (cloud) capability surface. The real implementation lives in * `remote.ts` and is the only place that reads `BROWSERBASE_API_KEY`. The @@ -26,6 +44,10 @@ export interface RemoteCapability { autoSelectRemoteTarget(): ConnectionTarget | null; /** Stagehand options for a remote (BROWSERBASE) session. */ remoteStagehandOptions(): StagehandConstructorOptions; + /** Map a failed remote `stagehand.init()` to an actionable message + code. */ + classifyRemoteInitError(error: unknown): RemoteInitErrorClassification; + /** Remediation strings for driver init failures. */ + driverInitHints(): DriverInitHints; /** Doctor readiness check for remote/Browserbase. */ remoteDoctorCheck(env: NodeJS.ProcessEnv): RemoteDoctorResult; } diff --git a/packages/cli/src/lib/driver/remote.disabled.ts b/packages/cli/src/lib/driver/remote.disabled.ts index a660d2ffd..e573c0283 100644 --- a/packages/cli/src/lib/driver/remote.disabled.ts +++ b/packages/cli/src/lib/driver/remote.disabled.ts @@ -1,5 +1,7 @@ import type { + DriverInitHints, RemoteDoctorResult, + RemoteInitErrorClassification, StagehandConstructorOptions, } from "./remote-types.js"; import type { ConnectionTarget } from "./types.js"; @@ -25,6 +27,27 @@ export function remoteStagehandOptions(): StagehandConstructorOptions { throw new Error(DISABLED_MESSAGE); } +export function classifyRemoteInitError( + error: unknown, +): RemoteInitErrorClassification { + // Remote targets cannot be selected in a local-only build, so this is only + // reachable if a remote error somehow surfaces anyway; preserve it as-is. + return { + code: "remote_session_create_failed", + message: error instanceof Error ? error.message : String(error), + }; +} + +export function driverInitHints(): DriverInitHints { + // Key-free variants: a local-only artifact must not mention the API key. + return { + chromeNotFound: + "No Chrome or Chromium found on this machine. Install one (Linux: apt install chromium \u00b7 macOS: brew install --cask google-chrome, or Chromium with CHROME_PATH set) or attach to a running browser with --cdp .", + repeatedInitFailure: + " (failing repeatedly — check your browser setup or run browse doctor)", + }; +} + export function remoteDoctorCheck(): RemoteDoctorResult { return { ok: true, message: "remote disabled (local-only build)" }; } diff --git a/packages/cli/src/lib/driver/remote.ts b/packages/cli/src/lib/driver/remote.ts index c10449a2d..16dcd2a9c 100644 --- a/packages/cli/src/lib/driver/remote.ts +++ b/packages/cli/src/lib/driver/remote.ts @@ -1,5 +1,9 @@ +import { StatusCodes } from "http-status-codes"; + import type { + DriverInitHints, RemoteDoctorResult, + RemoteInitErrorClassification, StagehandConstructorOptions, } from "./remote-types.js"; import type { ConnectionTarget } from "./types.js"; @@ -38,6 +42,51 @@ export function remoteStagehandOptions(): StagehandConstructorOptions { }; } +/** + * Map a failed remote `stagehand.init()` to an actionable message and a + * stable result code. Browserbase SDK errors carry an HTTP `status`. + */ +export function classifyRemoteInitError( + error: unknown, +): RemoteInitErrorClassification { + const status = (error as { status?: unknown } | null | undefined)?.status; + const httpStatus = typeof status === "number" ? status : undefined; + const original = error instanceof Error ? error.message : String(error); + + if (httpStatus === StatusCodes.UNAUTHORIZED) { + return { + code: "remote_auth_401", + httpStatus, + message: + "Browserbase rejected your BROWSERBASE_API_KEY (401 Unauthorized). A set key makes browse default to remote mode. Check the key at https://browserbase.com/settings, run without one using --local (browse open --local), or diagnose with browse doctor.", + }; + } + + if (httpStatus === StatusCodes.FORBIDDEN) { + return { + code: "remote_auth_403", + httpStatus, + message: + "Browserbase refused this request (403 Forbidden). Your BROWSERBASE_API_KEY may lack access to this project, or your plan may not allow this session type. Check the key at https://browserbase.com/settings, run without one using --local (browse open --local), or diagnose with browse doctor.", + }; + } + + return { + code: "remote_session_create_failed", + ...(httpStatus !== undefined ? { httpStatus } : {}), + message: `Failed to start a remote (Browserbase) session: ${original}\nRun browse doctor to diagnose remote connectivity.`, + }; +} + +export function driverInitHints(): DriverInitHints { + return { + chromeNotFound: + "No Chrome or Chromium found on this machine. Install one (Linux: apt install chromium \u00b7 macOS: brew install --cask google-chrome, or Chromium with CHROME_PATH set), attach to a running browser with --cdp , or set BROWSERBASE_API_KEY to use a remote browser.", + repeatedInitFailure: + " (failing repeatedly — fix BROWSERBASE_API_KEY, use --local, or run browse doctor)", + }; +} + export function remoteDoctorCheck(env: NodeJS.ProcessEnv): RemoteDoctorResult { if (env.BROWSERBASE_API_KEY) { return { ok: true, message: "BROWSERBASE_API_KEY is set" }; diff --git a/packages/cli/src/lib/driver/session-manager.ts b/packages/cli/src/lib/driver/session-manager.ts index 55a1cd4f9..23d9fac69 100644 --- a/packages/cli/src/lib/driver/session-manager.ts +++ b/packages/cli/src/lib/driver/session-manager.ts @@ -7,6 +7,7 @@ import { } from "./commands/selectors.js"; import { executeDriverCommand } from "./commands/registry.js"; import type { DriverCommandName } from "./commands/types.js"; +import { DriverError } from "./errors.js"; import { discoverLocalCdp } from "./local-cdp-discovery.js"; import { NetworkCapture } from "./network-capture.js"; import { getRemote } from "./remote-binding.js"; @@ -21,15 +22,49 @@ export type DriverContext = Stagehand["context"]; export type DriverPage = Awaited>; const INIT_FAILURE_RETRY_MS = 5_000; +const INIT_FAILURE_RETRY_MAX_MS = 60_000; + +// chrome-launcher reports "no Chrome on this machine" with these codes (its +// LaunchErrorCodes const enum, which can't be imported directly: const enums +// are erased at build time and isolatedModules forbids cross-module access). +const CHROME_NOT_FOUND_ERROR_CODES = new Set([ + "ERR_LAUNCHER_NOT_INSTALLED", + "ERR_LAUNCHER_PATH_NOT_SET", +]); interface InitFailure { error: unknown; retryAt: number; } +/** + * Exponential backoff for cached init failures: 5s, 10s, 20s, ... capped at + * 1 minute. Prevents agents stuck in retry loops from hammering init while + * still allowing a quick retry after the first failure. + */ +export function initFailureBackoffMs(consecutiveFailures: number): number { + const attempt = Math.max(1, consecutiveFailures); + return Math.min( + INIT_FAILURE_RETRY_MS * 2 ** (attempt - 1), + INIT_FAILURE_RETRY_MAX_MS, + ); +} + +export function isChromeNotFoundError(error: unknown): boolean { + const code = (error as { code?: unknown } | null | undefined)?.code; + if (typeof code === "string" && CHROME_NOT_FOUND_ERROR_CODES.has(code)) { + return true; + } + return ( + error instanceof Error && + error.message.includes("No Chrome installations found") + ); +} + export class DriverSessionManager { readonly network: NetworkCapture; + private consecutiveInitFailures = 0; private context: DriverContext | null = null; private initFailure: InitFailure | null = null; private initPromise: Promise | null = null; @@ -116,6 +151,7 @@ export class DriverSessionManager { this.stagehand = null; this.context = null; this.initFailure = null; + this.consecutiveInitFailures = 0; await this.network.disable().catch(() => undefined); if (stagehand) { await stagehand.close().catch(() => undefined); @@ -198,8 +234,9 @@ export class DriverSessionManager { return page; } - throw new Error( + throw new DriverError( `No active page in session "${this.session}". Run browse open --session ${this.session} or browse tab new --session ${this.session}.`, + { code: "no_active_page" }, ); } @@ -225,13 +262,17 @@ export class DriverSessionManager { this.initPromise = this.initialize() .then(() => { this.initFailure = null; + this.consecutiveInitFailures = 0; }) - .catch((error: unknown) => { + .catch(async (error: unknown) => { + this.consecutiveInitFailures += 1; + const failure = await this.markRepeatedInitFailure(error); this.initFailure = { - error, - retryAt: Date.now() + INIT_FAILURE_RETRY_MS, + error: failure, + retryAt: + Date.now() + initFailureBackoffMs(this.consecutiveInitFailures), }; - throw error; + throw failure; }) .finally(() => { this.initPromise = null; @@ -239,6 +280,17 @@ export class DriverSessionManager { return this.initPromise; } + private async markRepeatedInitFailure(error: unknown): Promise { + if (this.consecutiveInitFailures < 3 || !(error instanceof Error)) { + return error; + } + const hint = (await getRemote()).driverInitHints().repeatedInitFailure; + if (!error.message.includes(hint)) { + error.message += hint; + } + return error; + } + private async initialize(): Promise { const resolvedTarget = await this.resolveTarget(); const options = await this.stagehandOptions(resolvedTarget); @@ -248,7 +300,7 @@ export class DriverSessionManager { await stagehand.init(); } catch (error) { await stagehand.close().catch(() => undefined); - throw error; + throw await describeInitError(error, resolvedTarget); } this.stagehand = stagehand; this.context = stagehand.context; @@ -311,3 +363,35 @@ async function safeTitle(page: DriverPage): Promise { return ""; } } + +/** + * Turn raw `stagehand.init()` failures into typed, actionable errors. Remote + * failures are classified by the remote capability (401/403/etc.); a missing + * local Chrome gets install/escape-hatch guidance. Anything else is rethrown + * unchanged. + */ +async function describeInitError( + error: unknown, + target: ConnectionTarget, +): Promise { + if (error instanceof DriverError) return error; + + if (target.kind === "remote") { + const { code, httpStatus, message } = ( + await getRemote() + ).classifyRemoteInitError(error); + return new DriverError(message, { cause: error, code, httpStatus }); + } + + if (target.kind === "managed-local" && isChromeNotFoundError(error)) { + return new DriverError( + (await getRemote()).driverInitHints().chromeNotFound, + { + cause: error, + code: "no_chrome_found", + }, + ); + } + + return error; +} diff --git a/packages/cli/tests/driver-errors.test.ts b/packages/cli/tests/driver-errors.test.ts new file mode 100644 index 000000000..b351a09ba --- /dev/null +++ b/packages/cli/tests/driver-errors.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; + +import { DriverError } from "../src/lib/driver/errors.js"; +import { + ErrorResponseSchema, + serializeResponse, +} from "../src/lib/driver/daemon/protocol.js"; +import { classifyRemoteInitError } from "../src/lib/driver/remote.js"; +import { + initFailureBackoffMs, + isChromeNotFoundError, +} from "../src/lib/driver/session-manager.js"; + +describe("classifyRemoteInitError", () => { + it("maps 401 to an actionable invalid-key message", () => { + const error = Object.assign(new Error("401 Unauthorized"), { + status: 401, + }); + const classified = classifyRemoteInitError(error); + expect(classified.code).toBe("remote_auth_401"); + expect(classified.httpStatus).toBe(401); + expect(classified.message).toContain("BROWSERBASE_API_KEY"); + expect(classified.message).toContain("--local"); + expect(classified.message).toContain("browse doctor"); + }); + + it("maps 403 to a permissions/plan message with the same escape hatches", () => { + const error = Object.assign(new Error("403 Forbidden"), { status: 403 }); + const classified = classifyRemoteInitError(error); + expect(classified.code).toBe("remote_auth_403"); + expect(classified.httpStatus).toBe(403); + expect(classified.message).toContain("--local"); + expect(classified.message).toContain("browse doctor"); + }); + + it("preserves the original message for other failures", () => { + const classified = classifyRemoteInitError( + new Error("session quota exceeded"), + ); + expect(classified.code).toBe("remote_session_create_failed"); + expect(classified.httpStatus).toBeUndefined(); + expect(classified.message).toContain("session quota exceeded"); + expect(classified.message).toContain("browse doctor"); + }); + + it("handles non-Error values and non-numeric statuses", () => { + const classified = classifyRemoteInitError("boom"); + expect(classified.code).toBe("remote_session_create_failed"); + expect(classified.message).toContain("boom"); + }); +}); + +describe("initFailureBackoffMs", () => { + it.each([ + { failureCount: 1, expected: 5_000, description: "doubles from 5s" }, + { failureCount: 2, expected: 10_000, description: "doubles from 5s" }, + { failureCount: 3, expected: 20_000, description: "doubles from 5s" }, + { failureCount: 4, expected: 40_000, description: "doubles from 5s" }, + { failureCount: 5, expected: 60_000, description: "caps at 1 minute" }, + { failureCount: 50, expected: 60_000, description: "caps at 1 minute" }, + { + failureCount: 0, + expected: 5_000, + description: "nonsense count -> first failure", + }, + { + failureCount: -3, + expected: 5_000, + description: "nonsense count -> first failure", + }, + ])( + "failure #$failureCount $description -> $expected ms", + ({ failureCount, expected }) => { + expect(initFailureBackoffMs(failureCount)).toBe(expected); + }, + ); +}); + +describe("isChromeNotFoundError", () => { + it("matches chrome-launcher not-installed and path-not-set codes", () => { + expect( + isChromeNotFoundError( + Object.assign(new Error("No Chrome installations found."), { + code: "ERR_LAUNCHER_NOT_INSTALLED", + }), + ), + ).toBe(true); + expect( + isChromeNotFoundError( + Object.assign(new Error("CHROME_PATH must be set"), { + code: "ERR_LAUNCHER_PATH_NOT_SET", + }), + ), + ).toBe(true); + }); + + it("matches the message shape when no code is present", () => { + expect( + isChromeNotFoundError(new Error("No Chrome installations found.")), + ).toBe(true); + }); + + it("does not match unrelated errors", () => { + expect(isChromeNotFoundError(new Error("ECONNREFUSED"))).toBe(false); + expect(isChromeNotFoundError(undefined)).toBe(false); + }); +}); + +describe("daemon error protocol", () => { + it("round-trips code and httpStatus on error responses", () => { + const line = serializeResponse({ + code: "remote_auth_401", + error: "Browserbase rejected your key", + httpStatus: 401, + id: "req-1", + type: "error", + }); + const parsed = ErrorResponseSchema.parse(JSON.parse(line)); + expect(parsed.code).toBe("remote_auth_401"); + expect(parsed.httpStatus).toBe(401); + }); + + it("stays backward compatible with old daemons that omit the new fields", () => { + const parsed = ErrorResponseSchema.parse({ + error: "plain failure", + type: "error", + }); + expect(parsed.code).toBeUndefined(); + expect(parsed.httpStatus).toBeUndefined(); + }); +}); + +describe("driverInitHints", () => { + it("mentions the API key in the full build", async () => { + const { driverInitHints } = await import("../src/lib/driver/remote.js"); + const hints = driverInitHints(); + expect(hints.chromeNotFound).toContain("BROWSERBASE_API_KEY"); + expect(hints.repeatedInitFailure).toContain("BROWSERBASE_API_KEY"); + }); + + it("stays key-free in the local-only capability", async () => { + const { driverInitHints } = await import( + "../src/lib/driver/remote.disabled.js" + ); + const hints = driverInitHints(); + expect(hints.chromeNotFound).not.toContain("BROWSERBASE_API_KEY"); + expect(hints.repeatedInitFailure).not.toContain("BROWSERBASE_API_KEY"); + expect(hints.chromeNotFound).toContain("--cdp"); + }); +}); + +describe("DriverError", () => { + it("carries code, httpStatus, and cause", () => { + const cause = new Error("401 Unauthorized"); + const error = new DriverError("actionable message", { + cause, + code: "remote_auth_401", + httpStatus: 401, + }); + expect(error.code).toBe("remote_auth_401"); + expect(error.httpStatus).toBe(401); + expect(error.cause).toBe(cause); + expect(error.message).toBe("actionable message"); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aab0eaa72..535721b73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,6 +106,9 @@ importers: fastest-levenshtein: specifier: ^1.0.16 version: 1.0.16 + http-status-codes: + specifier: ^2.3.0 + version: 2.3.0 ignore: specifier: ^7.0.5 version: 7.0.5