91 lines
3.2 KiB
TypeScript
91 lines
3.2 KiB
TypeScript
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<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;
|
|
|
|
/**
|
|
* `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<never> {
|
|
return {
|
|
ok: false,
|
|
code: this.code,
|
|
message: options.exposeMessage ? this.message : "Internal error",
|
|
retryable: this.retryable,
|
|
};
|
|
}
|
|
}
|