feat(rpc): add service errors and retryability classification

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 13:37:52 +05:30
co-authored by Claude Opus 5
parent 3e1d7db537
commit 796b19d923
3 changed files with 126 additions and 0 deletions
+70
View File
@@ -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<T>(value: T): ServiceResult<T> {
return { ok: true, value };
}
export function failure(code: string, message: string): ServiceResult<never> {
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<never> {
return {
ok: false,
code: this.code,
message: options.exposeMessage ? this.message : "Internal error",
retryable: this.retryable,
};
}
}
+3
View File
@@ -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";