feat(rpc): add service errors and retryability classification
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user