mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-09-11 20:29:11 +02:00
212 lines
7.4 KiB
TypeScript
212 lines
7.4 KiB
TypeScript
/**
|
|
* 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<string, string>;
|
|
}
|
|
|
|
/** 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<string, string>;
|
|
/** The `code` of a structured `{"code": ...}` body, else `null`. */
|
|
code: string | null;
|
|
/** The `params` of a structured body, else an empty object. */
|
|
params: Record<string, unknown>;
|
|
|
|
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<string, unknown> = {};
|
|
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<string, unknown>;
|
|
if (typeof record.code === "string") {
|
|
code = record.code;
|
|
if (record.params !== null && typeof record.params === "object") {
|
|
params = record.params as Record<string, unknown>;
|
|
}
|
|
}
|
|
}
|
|
} 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<string, string> | undefined,
|
|
): Record<string, string> {
|
|
const result: Record<string, string> = {};
|
|
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<string, string>)) {
|
|
result[key.toLowerCase()] = value;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const BY_STATUS = new Map<number, typeof DonutApiError>([
|
|
[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);
|
|
}
|