import type { ServiceResult } from "./types.ts"; export const RPC_ERROR_CODES = { /** The request never reached a handler: connection, timeout, 5xx. */ transport: "RPC_TRANSPORT", /** Input failed the contract's schema. */ invalid: "RPC_INVALID", /** The callee's permission check refused. */ denied: "RPC_DENIED", /** No such service or procedure on the callee. */ unknown: "RPC_UNKNOWN", /** The handler threw or returned a failure. */ handler: "RPC_HANDLER", /** Identity token missing, malformed, expired, or for another audience. */ identity: "RPC_IDENTITY", /** * The callee answered, but not with a ServiceResult — a proxy's HTML error * page, a truncated body, an unexpected shape. Distinct from `transport`: * something DID respond, so retrying returns the same thing. */ malformed: "RPC_MALFORMED", } as const; export type RpcErrorCode = (typeof RPC_ERROR_CODES)[keyof typeof RPC_ERROR_CODES]; /** Only a transport failure is worth retrying; everything else is final. */ function retryableFor(code: string): boolean { return code === RPC_ERROR_CODES.transport; } /** * 5xx, 429 and 408 mean "the callee could not answer, try later". Any other * 4xx is the callee saying no — retrying just repeats the same rejection. * * The range is bounded on BOTH sides deliberately: an unbounded `>= 500` * puts a garbage status like 1000 in the retryable bucket, and this function * is the sole gate the client and HTTP transport trust for retry safety. * An out-of-range value must fail closed, i.e. not retryable. */ export function isRetryableStatus(status: number): boolean { if (status === 408 || status === 429) return true; return status >= 500 && status <= 599; } export function success(value: T): ServiceResult { return { ok: true, value }; } export function failure(code: string, message: string): ServiceResult { return { ok: false, code, message, retryable: retryableFor(code) }; } export interface ToResultOptions { /** Include the original message. Off by default: it may name internals. */ exposeMessage?: boolean; } export class ServiceError extends Error { readonly code: string; readonly retryable: boolean; /** * `retryable` defaults to the code-derived value for callers that * construct a `ServiceError` directly. Pass it explicitly when relaying a * wrn result: the transport already computed the authoritative value * (e.g. a bounded HTTP-status check), and recomputing it here from the * code alone would silently flip it — `RPC_TRANSPORT` derives to `true`, * even for a non-retryable 403. */ constructor(code: string, message: string, retryable?: boolean) { super(message); this.name = "ServiceError"; this.code = code; this.retryable = retryable ?? retryableFor(code); } /** * Convert to a wrn result. The message is replaced unless explicitly * exposed: a handler's error text routinely names tables, hosts, or * credentials, and this value crosses an app boundary. */ toResult(options: ToResultOptions = {}): ServiceResult { return { ok: false, code: this.code, message: options.exposeMessage ? this.message : "Internal error", retryable: this.retryable, }; } }