Skip to content

Commit bc88917

Browse files
authored
Add error cause (#556)
1 parent 918d07a commit bc88917

File tree

4 files changed

+284
-2
lines changed

4 files changed

+284
-2
lines changed

packages/sdk/src/errors.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ export interface ServerErrorOptions {
137137
code?: number;
138138
message: string;
139139
details?: Record<string, unknown> | null;
140+
cause?: ServerError | null;
140141
}
141142

142143
// =========================================================== //
@@ -249,6 +250,13 @@ export type ConnectionErrorDetail =
249250
* - `kind` — the error category (e.g. `"NotAllowed"`, `"NotFound"`)
250251
* - `code` — legacy JSON-RPC numeric error code (0 when unavailable)
251252
* - `details` — kind-specific structured details from the server (`{ kind, details? }` format)
253+
* - `cause` — optional inner `ServerError` forming a recursive error chain
254+
*
255+
* The `cause` field mirrors Rust's `Option<Box<Error>>` — each error can
256+
* optionally wrap an inner error, creating a stack of structured errors.
257+
* It is set as the native `Error.cause` so that standard JS tooling
258+
* (Node.js, Chrome DevTools, debuggers) displays the full chain
259+
* automatically using the `[cause]:` format.
252260
*
253261
* Use `instanceof` on subclasses (e.g. `NotFoundError`, `NotAllowedError`)
254262
* for type-safe matching, or check the `kind` property directly.
@@ -271,8 +279,16 @@ export class ServerError extends SurrealError {
271279
*/
272280
readonly details: ErrorDetail | undefined;
273281

282+
/**
283+
* The inner server error that caused this one, if any.
284+
* Forms a recursive chain matching Rust's `cause: Option<Box<Error>>`.
285+
* Set as the native `Error.cause` for standard JS error chaining.
286+
*/
287+
declare readonly cause: ServerError | undefined;
288+
274289
constructor(options: ServerErrorOptions) {
275-
super(options.message);
290+
const innerCause = options.cause ?? undefined;
291+
super(options.message, innerCause ? { cause: innerCause } : undefined);
276292
this.kind = options.kind;
277293
this.code = options.code ?? 0;
278294
this.details = (options.details ?? undefined) as ErrorDetail | undefined;

packages/sdk/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ export * from "./api";
22
export * from "./cbor";
33
export * from "./engine";
44
export * from "./errors";
5-
export { parseRpcError, type RpcErrorObject } from "./internal/parse-error";
5+
export { parseRpcError, type RpcErrorCause, type RpcErrorObject } from "./internal/parse-error";
66
export * from "./types";
77
export * from "./utils";
88
export * from "./value";

packages/sdk/src/internal/parse-error.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@ import {
1212
ValidationError,
1313
} from "../errors";
1414

15+
/**
16+
* Recursive cause shape on the wire. Mirrors Rust's `cause: Option<Box<Error>>`.
17+
*/
18+
export interface RpcErrorCause {
19+
kind?: string;
20+
message: string;
21+
details?: Record<string, unknown> | null;
22+
cause?: RpcErrorCause | null;
23+
}
24+
1525
/**
1626
* Raw error object shape from the server (RPC-level errors).
1727
*/
@@ -20,6 +30,7 @@ export interface RpcErrorObject {
2030
message: string;
2131
kind?: string;
2232
details?: Record<string, unknown> | null;
33+
cause?: RpcErrorCause | null;
2334
}
2435

2536
/**
@@ -32,6 +43,7 @@ export interface RpcQueryResultErrRaw {
3243
result: string;
3344
kind?: string;
3445
details?: Record<string, unknown> | null;
46+
cause?: RpcErrorCause | null;
3547
}
3648

3749
/**
@@ -70,6 +82,23 @@ function resolveKind(kind: string | undefined, code: number | undefined): string
7082
return "Internal";
7183
}
7284

85+
/**
86+
* Recursively parse a cause chain from the wire format into a `ServerError` chain.
87+
* Each cause becomes a fully typed `ServerError` (or subclass) with its own
88+
* `cause` set as the native `Error.cause`, so the entire chain is visible
89+
* in standard JS tooling.
90+
*/
91+
function parseCause(raw: RpcErrorCause | null | undefined): ServerError | undefined {
92+
if (!raw) return undefined;
93+
const kind = resolveKind(raw.kind, undefined);
94+
return createServerError({
95+
kind,
96+
message: raw.message,
97+
details: raw.details,
98+
cause: parseCause(raw.cause) ?? null,
99+
});
100+
}
101+
73102
/**
74103
* Factory that creates the correct `ServerError` subclass based on `kind`.
75104
* Unknown kinds produce a plain `ServerError` instance (forward-compatible).
@@ -110,6 +139,7 @@ export function parseRpcError(raw: RpcErrorObject): ServerError {
110139
code: raw.code,
111140
message: raw.message,
112141
details: raw.details,
142+
cause: parseCause(raw.cause) ?? null,
113143
});
114144
}
115145

@@ -142,5 +172,6 @@ export function parseQueryError(raw: RpcQueryResultErrRaw): ServerError {
142172
code: 0,
143173
message: raw.result,
144174
details,
175+
cause: parseCause(raw.cause) ?? null,
145176
});
146177
}

packages/tests/unit/errors/server-error.test.ts

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,241 @@ describe("ServerError", () => {
599599
});
600600
});
601601

602+
// =========================================================== //
603+
// Recursive cause chain //
604+
// =========================================================== //
605+
606+
describe("cause chain", () => {
607+
test("RPC error with single cause", () => {
608+
const err = parseRpcError({
609+
code: -32000,
610+
kind: "Internal",
611+
message: "Outer error",
612+
cause: {
613+
kind: "NotFound",
614+
message: "Inner: table missing",
615+
details: { kind: "Table", details: { name: "users" } },
616+
},
617+
});
618+
619+
expect(err).toBeInstanceOf(InternalError);
620+
expect(err.message).toBe("Outer error");
621+
622+
expect(err.cause).toBeDefined();
623+
const inner = err.cause as ServerError;
624+
expect(inner).toBeInstanceOf(NotFoundError);
625+
expect(inner).toBeInstanceOf(ServerError);
626+
expect(inner.kind).toBe("NotFound");
627+
expect(inner.message).toBe("Inner: table missing");
628+
expect((inner as NotFoundError).tableName).toBe("users");
629+
expect(inner.cause).toBeUndefined();
630+
});
631+
632+
test("RPC error with deeply nested cause chain", () => {
633+
const err = parseRpcError({
634+
code: -32002,
635+
kind: "NotAllowed",
636+
message: "Permission denied",
637+
cause: {
638+
kind: "Validation",
639+
message: "Bad input",
640+
cause: {
641+
kind: "Internal",
642+
message: "Root cause",
643+
},
644+
},
645+
});
646+
647+
expect(err).toBeInstanceOf(NotAllowedError);
648+
expect(err.message).toBe("Permission denied");
649+
650+
expect(err.cause).toBeDefined();
651+
const mid = err.cause as ServerError;
652+
expect(mid).toBeInstanceOf(ValidationError);
653+
expect(mid.message).toBe("Bad input");
654+
655+
expect(mid.cause).toBeDefined();
656+
const root = mid.cause as ServerError;
657+
expect(root).toBeInstanceOf(InternalError);
658+
expect(root.message).toBe("Root cause");
659+
expect(root.cause).toBeUndefined();
660+
});
661+
662+
test("query error with cause", () => {
663+
const err = parseQueryError({
664+
status: "ERR",
665+
time: "1ms",
666+
result: "Query failed",
667+
kind: "Query",
668+
details: { kind: "Cancelled" },
669+
cause: {
670+
kind: "Internal",
671+
message: "Backend unavailable",
672+
},
673+
});
674+
675+
expect(err).toBeInstanceOf(QueryError);
676+
expect((err as QueryError).isCancelled).toBe(true);
677+
678+
expect(err.cause).toBeDefined();
679+
const inner = err.cause as ServerError;
680+
expect(inner).toBeInstanceOf(InternalError);
681+
expect(inner.message).toBe("Backend unavailable");
682+
});
683+
684+
test("cause with null or missing cause terminates chain", () => {
685+
const err = parseRpcError({
686+
code: -32000,
687+
kind: "Internal",
688+
message: "Outer",
689+
cause: {
690+
kind: "Internal",
691+
message: "Inner",
692+
cause: null,
693+
},
694+
});
695+
696+
expect(err.cause).toBeDefined();
697+
expect((err.cause as ServerError).cause).toBeUndefined();
698+
});
699+
700+
test("null cause on top-level produces no cause", () => {
701+
const err = parseRpcError({
702+
code: -32000,
703+
kind: "Internal",
704+
message: "No cause",
705+
cause: null,
706+
});
707+
708+
expect(err.cause).toBeUndefined();
709+
});
710+
711+
test("missing cause field produces no cause", () => {
712+
const err = parseRpcError({
713+
code: -32000,
714+
kind: "Internal",
715+
message: "No cause",
716+
});
717+
718+
expect(err.cause).toBeUndefined();
719+
});
720+
721+
test("cause inherits correct subclass based on kind", () => {
722+
const err = parseRpcError({
723+
code: -32000,
724+
kind: "Internal",
725+
message: "Wrapper",
726+
cause: {
727+
kind: "AlreadyExists",
728+
message: "Duplicate record",
729+
details: { kind: "Record", details: { id: "person:1" } },
730+
},
731+
});
732+
733+
expect(err.cause).toBeDefined();
734+
const inner = err.cause as AlreadyExistsError;
735+
expect(inner).toBeInstanceOf(AlreadyExistsError);
736+
expect(inner.recordId).toBe("person:1");
737+
});
738+
739+
test("cause with unknown kind creates base ServerError", () => {
740+
const err = parseRpcError({
741+
code: -32000,
742+
kind: "Internal",
743+
message: "Wrapper",
744+
cause: {
745+
kind: "FutureKind",
746+
message: "From a newer server",
747+
details: { kind: "NewDetail" },
748+
},
749+
});
750+
751+
expect(err.cause).toBeDefined();
752+
const inner = err.cause as ServerError;
753+
expect(inner).toBeInstanceOf(ServerError);
754+
expect(inner).not.toBeInstanceOf(InternalError);
755+
expect(inner.kind).toBe("FutureKind");
756+
expect(inner.details).toEqual({ kind: "NewDetail" });
757+
});
758+
759+
test("cause is the native Error.cause (JS error chaining)", () => {
760+
const err = parseRpcError({
761+
code: -32000,
762+
kind: "Internal",
763+
message: "Outer",
764+
cause: {
765+
kind: "NotFound",
766+
message: "Inner",
767+
},
768+
});
769+
770+
const nativeCause = (err as Error).cause;
771+
expect(nativeCause).toBe(err.cause);
772+
expect(nativeCause).toBeInstanceOf(NotFoundError);
773+
});
774+
775+
test("ServerError constructed directly with cause", () => {
776+
const inner = new NotFoundError({
777+
kind: "NotFound",
778+
message: "table missing",
779+
});
780+
781+
const outer = new ServerError({
782+
kind: "Internal",
783+
message: "wrapped",
784+
cause: inner,
785+
});
786+
787+
expect(outer.cause).toBe(inner);
788+
expect((outer as Error).cause).toBe(inner);
789+
expect(outer.cause).toBeInstanceOf(NotFoundError);
790+
});
791+
792+
test("ServerError constructed without cause has undefined cause", () => {
793+
const err = new ServerError({
794+
kind: "Internal",
795+
message: "no cause",
796+
});
797+
798+
expect(err.cause).toBeUndefined();
799+
});
800+
801+
test("thrown error preserves cause chain when caught", () => {
802+
const err = parseRpcError({
803+
code: -32002,
804+
kind: "NotAllowed",
805+
message: "Permission denied",
806+
cause: {
807+
kind: "Validation",
808+
message: "Invalid token format",
809+
cause: {
810+
kind: "Internal",
811+
message: "Root cause: decode failed",
812+
},
813+
},
814+
});
815+
816+
try {
817+
throw err;
818+
} catch (caught) {
819+
expect(caught).toBeInstanceOf(NotAllowedError);
820+
const e = caught as ServerError;
821+
expect(e.message).toBe("Permission denied");
822+
823+
expect(e.cause).toBeDefined();
824+
const mid = e.cause as ServerError;
825+
expect(mid).toBeInstanceOf(ValidationError);
826+
expect(mid.message).toBe("Invalid token format");
827+
828+
expect(mid.cause).toBeDefined();
829+
const root = mid.cause as ServerError;
830+
expect(root).toBeInstanceOf(InternalError);
831+
expect(root.message).toBe("Root cause: decode failed");
832+
expect(root.cause).toBeUndefined();
833+
}
834+
});
835+
});
836+
602837
// =========================================================== //
603838
// ResponseError backward compat alias //
604839
// =========================================================== //

0 commit comments

Comments
 (0)