diff --git a/packages/rpc/src/errors.ts b/packages/rpc/src/errors.ts new file mode 100644 index 00000000..e19d8514 --- /dev/null +++ b/packages/rpc/src/errors.ts @@ -0,0 +1,70 @@ +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", +} 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 and 429 mean "the callee could not answer, try later". A 4xx is the + * callee saying no — retrying it just repeats the same rejection. + */ +export function isRetryableStatus(status: number): boolean { + return status >= 500 || status === 429; +} + +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; + + constructor(code: string, message: string) { + super(message); + this.name = "ServiceError"; + this.code = code; + this.retryable = retryableFor(code); + } + + /** + * Convert to a wire 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, + }; + } +} diff --git a/packages/rpc/src/index.ts b/packages/rpc/src/index.ts index f2292072..054b9d19 100644 --- a/packages/rpc/src/index.ts +++ b/packages/rpc/src/index.ts @@ -17,3 +17,6 @@ export type { ServiceContract, ServiceResult, } from "./types.ts"; + +export { RPC_ERROR_CODES, ServiceError, failure, isRetryableStatus, success } from "./errors.ts"; +export type { RpcErrorCode, ToResultOptions } from "./errors.ts"; diff --git a/packages/rpc/test/errors.test.ts b/packages/rpc/test/errors.test.ts new file mode 100644 index 00000000..98e2bc0e --- /dev/null +++ b/packages/rpc/test/errors.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { + RPC_ERROR_CODES, + ServiceError, + failure, + isRetryableStatus, + success, +} from "../src/errors.ts"; + +describe("rpc errors", () => { + test("success and failure build the result shape", () => { + expect(success(42)).toEqual({ ok: true, value: 42 }); + const f = failure(RPC_ERROR_CODES.denied, "Forbidden"); + expect(f.ok).toBe(false); + if (!f.ok) { + expect(f.code).toBe("RPC_DENIED"); + expect(f.retryable).toBe(false); + } + }); + + test("only transport failures are retryable", () => { + // 5xx and 429 are the callee saying "try again"; everything else is final. + expect(isRetryableStatus(500)).toBe(true); + expect(isRetryableStatus(503)).toBe(true); + expect(isRetryableStatus(429)).toBe(true); + expect(isRetryableStatus(400)).toBe(false); + expect(isRetryableStatus(403)).toBe(false); + expect(isRetryableStatus(404)).toBe(false); + expect(isRetryableStatus(200)).toBe(false); + }); + + test("a denial is never retryable", () => { + const f = failure(RPC_ERROR_CODES.denied, "Forbidden"); + if (!f.ok) expect(f.retryable).toBe(false); + }); + + test("ServiceError carries a code and does not leak a cause into its message", () => { + const error = new ServiceError(RPC_ERROR_CODES.handler, "Something failed"); + expect(error.name).toBe("ServiceError"); + expect(error.code).toBe("RPC_HANDLER"); + expect(error.message).toBe("Something failed"); + expect(error.retryable).toBe(false); + }); + + test("toResult produces an opaque failure", () => { + const error = new ServiceError(RPC_ERROR_CODES.handler, "db password is hunter2"); + const result = error.toResult({ exposeMessage: false }); + if (!result.ok) { + expect(result.message).toBe("Internal error"); + expect(result.message).not.toContain("hunter2"); + } + }); +});