// CompanionError — the one error type every SDK helper rejects with (README // contract: consumers `switch` on `.code` instead of string-matching prose). // Lives in its own module so call.ts can use it without a runtime cycle // through client.ts (client.ts re-exports it, keeping the public import path // `@pouchy_ai/companion-sdk` → `CompanionError` unchanged). // Type-only import: protocol.ts imports nothing at runtime, so this cannot // re-create the client.ts cycle this module exists to avoid. import type { CompanionErrorCode, ControlErrorCode } from './protocol'; /** Codes the SDK itself synthesizes (status 0 — never sent by the server). * Runtime array so the drift test can assert every `new CompanionError(...)` * call site uses a declared code; APPEND-ONLY like the server vocabulary. */ export const SDK_SYNTHESIZED_ERROR_CODES = [ 'reply_timeout', // awaitReply/replyTimeoutMs elapsed before the reply 'needs_event_stream', // turn paused on tools but start() was never called 'not_connected', // a call/stream helper was used before connect 'missing_option', // a required option was omitted (e.g. sessionId) 'not_representative', // pairVisitor on a non-representative token 'call_unsupported', // startCall on a server/provider without voice 'call_connect_failed', // WebRTC/provider handshake failed 'call_dependency_missing', // optional voice dependency not installed 'aborted', // a caller-supplied AbortSignal cancelled the request (0.29.0) 'queued_offline', // queueOffline: the text was queued while offline and will send on reconnect (0.34.0) 'request_timeout', // requestTimeoutMs elapsed before response headers arrived (0.35.0) 'invalid_option', // an option's value is malformed and the SDK refused before sending (e.g. a turnId outside 1–256 chars, 0.72.0; 1–64 until 0.73.2) 'reply_unavailable', // awaitReply: the turnId already completed and the server no longer holds its reply (0.73.0) 'bad_response', // a 2xx whose body is not JSON — an intermediary (captive portal) answered; keeps the HTTP status (0.77.0). Also a streamed reply with no body or an unparseable done frame (was a code-less 502, CR-997) 'stream_incomplete', // a streamed reply ended without its done frame — the connection dropped or the server function was killed mid-turn; the turn's outcome is unknown (CR-997) 'call_dropped', // a live call ended because the line went away: WebRTC failed/closed, the data channel died, or the provider disconnected without naming an error (CR-974) 'call_provider_error' // a live call ended because the voice provider reported a failure; `message` is the provider's text (CR-974) ] as const; export type SdkSynthesizedErrorCode = (typeof SDK_SYNTHESIZED_ERROR_CODES)[number]; /** `control.error` STREAM codes the SDK itself synthesizes (never sent by the * server) — the stream-plane sibling of SDK_SYNTHESIZED_ERROR_CODES above. * Runtime array so the drift test can assert every locally-emitted * `control.error` envelope uses a declared code; APPEND-ONLY. */ export const SDK_STREAM_ERROR_CODES = [ 'stream_unauthorized', // the event stream got a permanent 401/403 and reconnect gave up 'stream_rejected' // the event stream got a terminal 404/410/400 (e.g. session_not_found) and stopped instead of reconnecting forever (CR-790) ] as const; export type SdkStreamErrorCode = (typeof SDK_STREAM_ERROR_CODES)[number]; /** `control.error` `code` values a consumer can switch on (0.30.0): the server * vocabulary (CONTROL_ERROR_CODES, drift-tested against the server list) plus * the SDK-synthesized stream codes — DERIVED from the runtime lists rather * than hand-retyped, same doctrine as CompanionErrorCodeValue below. The * `(string & {})` arm keeps the type open for codes newer than this SDK build * while preserving autocomplete. */ export type ControlErrorCodeValue = | ControlErrorCode | SdkStreamErrorCode // eslint-disable-next-line @typescript-eslint/ban-types | (string & {}); /** Thrown by every helper on an HTTP or client-side failure. `status` is the * HTTP status (0 for client-synthesized failures — timeouts, unsupported * environments, missing optional deps); `code` is machine-switchable when * the server (or the SDK itself) names a cause. On a `rate_limited` (429) * failure, `retryAfter` carries the server's Retry-After in SECONDS so a * consumer can back off precisely instead of guessing. */ /** `CompanionError.code` values: the server vocabulary (COMPANION_ERROR_CODES, * drift-tested against api-error.ts) plus the SDK-synthesized ones — DERIVED * from those runtime lists rather than hand-retyped, so a new code can never * silently miss this union again (the old copy had drifted 5 codes behind). * The `(string & {})` arm keeps the type open for codes newer than this SDK * build while preserving autocomplete. */ export type CompanionErrorCodeValue = | CompanionErrorCode | SdkSynthesizedErrorCode // eslint-disable-next-line @typescript-eslint/ban-types | (string & {}); export class CompanionError extends Error { status: number; code?: CompanionErrorCodeValue; retryAfter?: number; /** The server's error reference (`err_…`) on a persisted 5xx — the id a * `server_errors` row is keyed by, and the thing to quote to support. * Present only when the server minted one (a real server fault; never on a * 4xx or a transport failure). 0.56.0. */ errorId?: string; /** Why a refusal came from a DEGRADED rung rather than the ordinary limit * (CR-1040). Today one value: `'limiter_unreachable'` — the rate limiter * is down and the conversation is being served at a temporary half * budget, so a `rate_limited` 429 carrying it is NOT your permanent quota: * back off for `retryAfter` and expect the full budget back. Absent on * every ordinary failure. */ degraded?: string; constructor( message: string, status: number, code?: string, retryAfter?: number, errorId?: string, degraded?: string ) { super(message); this.name = 'CompanionError'; this.status = status; if (code) this.code = code; if (typeof retryAfter === 'number' && Number.isFinite(retryAfter)) this.retryAfter = retryAfter; if (typeof errorId === 'string' && errorId) this.errorId = errorId; if (typeof degraded === 'string' && degraded) this.degraded = degraded; } }