diff --git a/src/errors.ts b/src/errors.ts index ba7f8c5..02e444b 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -2089,3 +2089,39 @@ export class ChannelExhaustedError extends StellarSplitError { Object.setPrototypeOf(this, new.target.prototype); } } + +// --------------------------------------------------------------------------- +// SdkError / SdkErrorCode (issue #607) +// +// A typed, machine-checkable error code paired with a generic error class, +// so callers can switch on `err.code` instead of string-matching `.message`. +// --------------------------------------------------------------------------- + +/** Machine-readable error codes carried by {@link SdkError}. */ +export enum SdkErrorCode { + INVOICE_NOT_FOUND = "INVOICE_NOT_FOUND", + INSUFFICIENT_FUNDS = "INSUFFICIENT_FUNDS", + DEADLINE_EXPIRED = "DEADLINE_EXPIRED", + INVALID_RECIPIENT = "INVALID_RECIPIENT", + CONTRACT_REJECTED = "CONTRACT_REJECTED", + NETWORK_TIMEOUT = "NETWORK_TIMEOUT", + RATE_LIMITED = "RATE_LIMITED", +} + +/** Generic SDK error carrying a typed {@link SdkErrorCode} and optional details. */ +export class SdkError extends Error { + readonly code: SdkErrorCode; + readonly details?: unknown; + + constructor(message: string, code: SdkErrorCode, details?: unknown) { + super(message); + this.name = "SdkError"; + this.code = code; + this.details = details; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isSdkError(err: unknown): err is SdkError { + return err instanceof SdkError; +} diff --git a/src/index.ts b/src/index.ts index 2fe2b4a..c181bd8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -204,6 +204,10 @@ export { isFinalityTimeoutError, ApprovalTimeoutError, isApprovalTimeoutError, + // New: typed SdkError / SdkErrorCode (issue #607) + SdkError, + SdkErrorCode, + isSdkError, } from "./errors.js"; // Invoice metadata JSON Schema validator (issue #533) diff --git a/test/sdkError.test.ts b/test/sdkError.test.ts new file mode 100644 index 0000000..374cba6 --- /dev/null +++ b/test/sdkError.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import { SdkError, SdkErrorCode, isSdkError } from "../src/errors.js"; + +describe("SdkError", () => { + it("carries the given code, message, and details", () => { + const err = new SdkError("Invoice not found: inv-1", SdkErrorCode.INVOICE_NOT_FOUND, { + invoiceId: "inv-1", + }); + + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(SdkError); + expect(err.name).toBe("SdkError"); + expect(err.code).toBe(SdkErrorCode.INVOICE_NOT_FOUND); + expect(err.message).toBe("Invoice not found: inv-1"); + expect(err.details).toEqual({ invoiceId: "inv-1" }); + }); + + it("allows details to be omitted", () => { + const err = new SdkError("Rate limited", SdkErrorCode.RATE_LIMITED); + expect(err.details).toBeUndefined(); + }); + + it.each([ + SdkErrorCode.INVOICE_NOT_FOUND, + SdkErrorCode.INSUFFICIENT_FUNDS, + SdkErrorCode.DEADLINE_EXPIRED, + SdkErrorCode.INVALID_RECIPIENT, + SdkErrorCode.CONTRACT_REJECTED, + SdkErrorCode.NETWORK_TIMEOUT, + SdkErrorCode.RATE_LIMITED, + ])("preserves code %s", (code) => { + const err = new SdkError("some message", code); + expect(err.code).toBe(code); + }); +}); + +describe("isSdkError", () => { + it("returns true for an SdkError instance", () => { + const err = new SdkError("boom", SdkErrorCode.NETWORK_TIMEOUT); + expect(isSdkError(err)).toBe(true); + }); + + it("returns false for a plain Error", () => { + expect(isSdkError(new Error("plain"))).toBe(false); + }); + + it("returns false for non-error values", () => { + expect(isSdkError(null)).toBe(false); + expect(isSdkError(undefined)).toBe(false); + expect(isSdkError("string")).toBe(false); + expect(isSdkError({ code: SdkErrorCode.RATE_LIMITED })).toBe(false); + }); +});