/** * Exceptions thrown by the Donut Browser SDK. * * The local REST API answers with a plain-text body and one of a small set of * statuses. Each status means one thing, so each gets its own class and a * caller can branch on `instanceof` instead of on a number: * * | Status | Class | Meaning | * | -----: | --------------------- | ----------------------------------------- | * | 400 | `ValidationError` | Malformed request, duplicate name | * | 401 | `Unauthorized` | Missing or wrong bearer token | * | 402 | `PaymentRequired` | Automation needs an active paid plan | * | 403 | `Forbidden` | Terms not accepted, or not signed in | * | 404 | `NotFound` | No such profile, group, proxy, ... | * | 408 | `RequestTimeout` | `agent/pick` waited and nothing was picked | * | 409 | `Conflict` | Something else holds the profile | * | 429 | `RateLimited` | Quota spent; see `retryAfter` | * | 500 | `ServerError` | Internal failure | * | 502 | `BadGateway` | The browser or relay answered wrongly | * | 503 | `ServiceUnavailable` | Cloud, fleet or lock service unreachable | * * Some bodies are the structured `{"code": ..., "params": {...}}` strings the * desktop app shares with its own frontend. When one arrives, `code` and * `params` are filled in; otherwise `code` is `null` and `body` holds the * diagnostic text as sent. */ /** Base class for everything this package throws. */ export class DonutError extends Error { constructor(message: string, options?: ErrorOptions) { super(message, options); this.name = new.target.name; } } /** * The app could not be reached at all. * * Usually means the local API is switched off, is listening on another port, * or the desktop app is not running. */ export class DonutConnectionError extends DonutError {} export interface DonutApiErrorInit { method?: string; path?: string; headers?: Headers | Record; } /** The app answered, and the answer was an error status. */ export class DonutApiError extends DonutError { status: number; body: string; method: string; path: string; headers: Record; /** The `code` of a structured `{"code": ...}` body, else `null`. */ code: string | null; /** The `params` of a structured body, else an empty object. */ params: Record; constructor(status: number, body: string, init: DonutApiErrorInit = {}) { const method = init.method ?? ""; const path = init.path ?? ""; const headers = normaliseHeaders(init.headers); let code: string | null = null; let params: Record = {}; const trimmed = body.trim(); if (trimmed.startsWith("{")) { try { const decoded: unknown = JSON.parse(trimmed); if (decoded !== null && typeof decoded === "object") { const record = decoded as Record; if (typeof record.code === "string") { code = record.code; if (record.params !== null && typeof record.params === "object") { params = record.params as Record; } } } } catch { // Not JSON after all; the plain text below is the whole story. } } const where = `${method} ${path}`.trim(); const detail = code ?? (trimmed || "(empty body)"); super(where ? `${status} on ${where}: ${detail}` : `${status}: ${detail}`); this.status = status; this.body = body; this.method = method; this.path = path; this.headers = headers; this.code = code; this.params = params; } } /** 400: the request was malformed, duplicated a name, or named something unsupported. */ export class ValidationError extends DonutApiError {} /** 401: no bearer token, the wrong one, or the local API has no token stored. */ export class Unauthorized extends DonutApiError {} /** 402: this action needs an active paid plan, or the proxy behind it lapsed. */ export class PaymentRequired extends DonutApiError {} /** 403: the Wayfern terms are not accepted, or this desktop is not signed in. */ export class Forbidden extends DonutApiError {} /** 404: no entity with that id. */ export class NotFound extends DonutApiError {} /** 408: `agentPick` waited its whole timeout and nothing was picked. */ export class RequestTimeout extends DonutApiError {} /** 409: something else holds the profile — a browser, a teammate, a remote session. */ export class Conflict extends DonutApiError {} /** * 500 and the other 5xx: the app, the fleet or an upstream failed. * * `BadGateway` and `ServiceUnavailable` extend this, so one * `instanceof ServerError` covers every server-side failure. */ export class ServerError extends DonutApiError {} /** 502: the browser or the relay did not answer the way it documents. */ export class BadGateway extends ServerError {} /** * 503: Donut cloud, the remote fleet, or the profile lock service is unreachable. * * Whatever was running keeps running: a 503 from `killProfile` or from stopping * a remote session means the browser is still up, not that it stopped. */ export class ServiceUnavailable extends ServerError {} /** * 429: the shared automation quota is spent. * * `retryAfter` is the number of seconds the server asked the caller to wait, * taken from the `Retry-After` response header. It is `null` only when the * header is missing or unreadable. */ export class RateLimited extends DonutApiError { retryAfter: number | null; constructor(status: number, body: string, init: DonutApiErrorInit = {}) { super(status, body, init); const raw = this.headers["retry-after"]; const seconds = raw === undefined ? Number.NaN : Number.parseInt(raw.trim(), 10); this.retryAfter = Number.isFinite(seconds) ? seconds : null; } } function normaliseHeaders( headers: Headers | Record | undefined, ): Record { const result: Record = {}; if (headers === undefined) { return result; } if (typeof (headers as Headers).forEach === "function" && !Array.isArray(headers)) { (headers as Headers).forEach((value, key) => { result[key.toLowerCase()] = value; }); return result; } for (const [key, value] of Object.entries(headers as Record)) { result[key.toLowerCase()] = value; } return result; } const BY_STATUS = new Map([ [400, ValidationError], [401, Unauthorized], [402, PaymentRequired], [403, Forbidden], [404, NotFound], [408, RequestTimeout], [409, Conflict], [429, RateLimited], [500, ServerError], [502, BadGateway], [503, ServiceUnavailable], ]); /** * Build the error that belongs to `status`. * * A status with no class of its own becomes a plain `DonutApiError`, so a * future status added to the app still throws something a caller can catch * rather than escaping as a decode failure. */ export function errorForStatus( status: number, body: string, init: DonutApiErrorInit = {}, ): DonutApiError { const known = BY_STATUS.get(status); if (known !== undefined) { return new known(status, body, init); } return status >= 500 ? new ServerError(status, body, init) : new DonutApiError(status, body, init); }